diff --git a/.coveragerc b/.coveragerc index d059d62b5f3..90b0a7f475d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -192,7 +192,7 @@ omit = homeassistant/components/mychevy.py homeassistant/components/*/mychevy.py - homeassistant/components/mysensors.py + homeassistant/components/mysensors/* homeassistant/components/*/mysensors.py homeassistant/components/neato.py diff --git a/.gitignore b/.gitignore index bf49a1b61c1..c2b0d964a62 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ desktop.ini # Secrets .lokalise_token + +# monkeytype +monkeytype.sqlite3 diff --git a/homeassistant/auth.py b/homeassistant/auth.py index 5e434b74ca8..a4e8ee05943 100644 --- a/homeassistant/auth.py +++ b/homeassistant/auth.py @@ -1,26 +1,27 @@ """Provide an authentication layer for Home Assistant.""" import asyncio import binascii -from collections import OrderedDict -from datetime import datetime, timedelta -import os import importlib import logging +import os import uuid +from collections import OrderedDict +from datetime import datetime, timedelta import attr import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant import data_entry_flow, requirements -from homeassistant.core import callback from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID -from homeassistant.util.decorator import Registry +from homeassistant.core import callback from homeassistant.util import dt as dt_util - +from homeassistant.util.decorator import Registry _LOGGER = logging.getLogger(__name__) +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth' AUTH_PROVIDERS = Registry() @@ -121,23 +122,12 @@ class User: is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) name = attr.ib(type=str, default=None) - # For persisting and see if saved? - # store = attr.ib(type=AuthStore, default=None) # List of credentials of a user. - credentials = attr.ib(type=list, default=attr.Factory(list)) + credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) # Tokens associated with a user. - refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict)) - - def as_dict(self): - """Convert user object to a dictionary.""" - return { - 'id': self.id, - 'is_owner': self.is_owner, - 'is_active': self.is_active, - 'name': self.name, - } + refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) @attr.s(slots=True) @@ -152,7 +142,7 @@ class RefreshToken: default=ACCESS_TOKEN_EXPIRATION) token = attr.ib(type=str, default=attr.Factory(lambda: generate_secret(64))) - access_tokens = attr.ib(type=list, default=attr.Factory(list)) + access_tokens = attr.ib(type=list, default=attr.Factory(list), cmp=False) @attr.s(slots=True) @@ -168,9 +158,10 @@ class AccessToken: default=attr.Factory(generate_secret)) @property - def expires(self): - """Return datetime when this token expires.""" - return self.created_at + self.refresh_token.access_token_expiration + def expired(self): + """Return if this token has expired.""" + expires = self.created_at + self.refresh_token.access_token_expiration + return dt_util.utcnow() > expires @attr.s(slots=True) @@ -281,7 +272,24 @@ class AuthManager: self.login_flow = data_entry_flow.FlowManager( hass, self._async_create_login_flow, self._async_finish_login_flow) - self.access_tokens = {} + self._access_tokens = {} + + @property + def active(self): + """Return if any auth providers are registered.""" + return bool(self._providers) + + @property + def support_legacy(self): + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False @property def async_auth_providers(self): @@ -317,13 +325,22 @@ class AuthManager: def async_create_access_token(self, refresh_token): """Create a new access token.""" access_token = AccessToken(refresh_token) - self.access_tokens[access_token.token] = access_token + self._access_tokens[access_token.token] = access_token return access_token @callback def async_get_access_token(self, token): """Get an access token.""" - return self.access_tokens.get(token) + tkn = self._access_tokens.get(token) + + if tkn is None: + return None + + if tkn.expired: + self._access_tokens.pop(token) + return None + + return tkn async def async_create_client(self, name, *, redirect_uris=None, no_secret=False): @@ -331,6 +348,16 @@ class AuthManager: 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) @@ -374,29 +401,36 @@ class AuthStore: def __init__(self, hass): """Initialize the auth store.""" self.hass = hass - self.users = None - self.clients = None - self._load_lock = asyncio.Lock(loop=hass.loop) + self._users = None + self._clients = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) async def credentials_for_provider(self, provider_type, provider_id): """Return credentials for specific auth provider type and id.""" - if self.users is None: + if self._users is None: await self.async_load() return [ credentials - for user in self.users.values() + for user in self._users.values() for credentials in user.credentials if (credentials.auth_provider_type == provider_type and credentials.auth_provider_id == provider_id) ] - async def async_get_user(self, user_id): - """Retrieve a user.""" - if self.users is None: + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: await self.async_load() - return self.users.get(user_id) + return list(self._users.values()) + + async def async_get_user(self, user_id): + """Retrieve a user.""" + if self._users is None: + await self.async_load() + + return self._users.get(user_id) async def async_get_or_create_user(self, credentials, auth_provider): """Get or create a new user for given credentials. @@ -404,7 +438,7 @@ class AuthStore: If link_user is passed in, the credentials will be linked to the passed in user if the credentials are new. """ - if self.users is None: + if self._users is None: await self.async_load() # New credentials, store in user @@ -412,7 +446,7 @@ class AuthStore: info = await auth_provider.async_user_meta_for_credentials( credentials) # Make owner and activate user if it's the first user. - if self.users: + if self._users: is_owner = False is_active = False else: @@ -424,11 +458,11 @@ class AuthStore: is_active=is_active, name=info.get('name'), ) - self.users[new_user.id] = new_user + self._users[new_user.id] = new_user await self.async_link_user(new_user, credentials) return new_user - for user in self.users.values(): + for user in self._users.values(): for creds in user.credentials: if (creds.auth_provider_type == credentials.auth_provider_type and creds.auth_provider_id == @@ -445,11 +479,19 @@ class AuthStore: async def async_remove_user(self, user): """Remove a user.""" - self.users.pop(user.id) + self._users.pop(user.id) await self.async_save() async def async_create_refresh_token(self, user, client_id): """Create a new token for a user.""" + local_user = await self.async_get_user(user.id) + if local_user is None: + raise ValueError('Invalid user') + + local_client = await self.async_get_client(client_id) + if local_client is None: + raise ValueError('Invalid client_id') + refresh_token = RefreshToken(user, client_id) user.refresh_tokens[refresh_token.token] = refresh_token await self.async_save() @@ -457,10 +499,10 @@ class AuthStore: async def async_get_refresh_token(self, token): """Get refresh token by token.""" - if self.users is None: + if self._users is None: await self.async_load() - for user in self.users.values(): + for user in self._users.values(): refresh_token = user.refresh_tokens.get(token) if refresh_token is not None: return refresh_token @@ -469,7 +511,7 @@ class AuthStore: async def async_create_client(self, name, redirect_uris, no_secret): """Create a new client.""" - if self.clients is None: + if self._clients is None: await self.async_load() kwargs = { @@ -481,23 +523,148 @@ class AuthStore: kwargs['secret'] = None client = Client(**kwargs) - self.clients[client.id] = client + self._clients[client.id] = client await self.async_save() return client - async def async_get_client(self, client_id): - """Get a client.""" - if self.clients is None: + async def async_get_clients(self): + """Return all clients.""" + if self._clients is None: await self.async_load() - return self.clients.get(client_id) + 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.""" - async with self._load_lock: - self.users = {} - self.clients = {} + data = await self._store.async_load() + + # Make sure that we're not overriding data if 2 loads happened at the + # same time + if self._users is not None: + return + + if data is None: + self._users = {} + self._clients = {} + return + + users = { + user_dict['id']: User(**user_dict) for user_dict in data['users'] + } + + for cred_dict in data['credentials']: + users[cred_dict['user_id']].credentials.append(Credentials( + id=cred_dict['id'], + is_new=False, + auth_provider_type=cred_dict['auth_provider_type'], + auth_provider_id=cred_dict['auth_provider_id'], + data=cred_dict['data'], + )) + + refresh_tokens = {} + + for rt_dict in data['refresh_tokens']: + token = RefreshToken( + id=rt_dict['id'], + user=users[rt_dict['user_id']], + client_id=rt_dict['client_id'], + created_at=dt_util.parse_datetime(rt_dict['created_at']), + access_token_expiration=timedelta( + seconds=rt_dict['access_token_expiration']), + token=rt_dict['token'], + ) + refresh_tokens[token.id] = token + users[rt_dict['user_id']].refresh_tokens[token.token] = token + + for ac_dict in data['access_tokens']: + refresh_token = refresh_tokens[ac_dict['refresh_token_id']] + token = AccessToken( + refresh_token=refresh_token, + created_at=dt_util.parse_datetime(ac_dict['created_at']), + token=ac_dict['token'], + ) + refresh_token.access_tokens.append(token) + + clients = { + cl_dict['id']: Client(**cl_dict) for cl_dict in data['clients'] + } + + self._users = users + self._clients = clients async def async_save(self): """Save users.""" - pass + users = [ + { + 'id': user.id, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'name': user.name, + } + for user in self._users.values() + ] + + credentials = [ + { + 'id': credential.id, + 'user_id': user.id, + 'auth_provider_type': credential.auth_provider_type, + 'auth_provider_id': credential.auth_provider_id, + 'data': credential.data, + } + for user in self._users.values() + for credential in user.credentials + ] + + refresh_tokens = [ + { + 'id': refresh_token.id, + 'user_id': user.id, + 'client_id': refresh_token.client_id, + 'created_at': refresh_token.created_at.isoformat(), + 'access_token_expiration': + refresh_token.access_token_expiration.total_seconds(), + 'token': refresh_token.token, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + ] + + access_tokens = [ + { + 'id': user.id, + 'refresh_token_id': refresh_token.id, + 'created_at': access_token.created_at.isoformat(), + 'token': access_token.token, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + for access_token in refresh_token.access_tokens + ] + + clients = [ + { + 'id': client.id, + 'name': client.name, + 'secret': client.secret, + 'redirect_uris': client.redirect_uris, + } + for client in self._clients.values() + ] + + data = { + 'users': users, + 'clients': clients, + 'credentials': credentials, + 'access_tokens': access_tokens, + 'refresh_tokens': refresh_tokens, + } + + await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth_providers/homeassistant.py b/homeassistant/auth_providers/homeassistant.py index c2db193ce1a..c4d2021f6ce 100644 --- a/homeassistant/auth_providers/homeassistant.py +++ b/homeassistant/auth_providers/homeassistant.py @@ -8,10 +8,10 @@ import voluptuous as vol from homeassistant import auth, data_entry_flow from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import json -PATH_DATA = '.users.json' +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_provider.homeassistant' CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ }, extra=vol.PREVENT_EXTRA) @@ -31,14 +31,22 @@ class InvalidUser(HomeAssistantError): class Data: """Hold the user data.""" - def __init__(self, path, data): + def __init__(self, hass): """Initialize the user data store.""" - self.path = path + self.hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None + + async def async_load(self): + """Load stored data.""" + data = await self._store.async_load() + if data is None: data = { 'salt': auth.generate_secret(), 'users': [] } + self._data = data @property @@ -99,14 +107,9 @@ class Data: else: raise InvalidUser - def save(self): + async def async_save(self): """Save data.""" - json.save_json(self.path, self._data) - - -def load_data(path): - """Load auth data.""" - return Data(path, json.load_json(path, None)) + await self._store.async_save(self._data) @auth.AUTH_PROVIDERS.register('homeassistant') @@ -121,12 +124,10 @@ class HassAuthProvider(auth.AuthProvider): async def async_validate_login(self, username, password): """Helper to validate a username and password.""" - def validate(): - """Validate creds.""" - data = self._auth_data() - data.validate_login(username, password) - - await self.hass.async_add_job(validate) + data = Data(self.hass) + await data.async_load() + await self.hass.async_add_executor_job( + data.validate_login, username, password) async def async_get_or_create_credentials(self, flow_result): """Get credentials based on the flow result.""" @@ -141,10 +142,6 @@ class HassAuthProvider(auth.AuthProvider): 'username': username }) - def _auth_data(self): - """Return the auth provider data.""" - return load_data(self.hass.config.path(PATH_DATA)) - class LoginFlow(data_entry_flow.FlowHandler): """Handler for the login flow.""" diff --git a/homeassistant/auth_providers/legacy_api_password.py b/homeassistant/auth_providers/legacy_api_password.py new file mode 100644 index 00000000000..510cc4d0279 --- /dev/null +++ b/homeassistant/auth_providers/legacy_api_password.py @@ -0,0 +1,104 @@ +""" +Support Legacy API password auth provider. + +It will be removed when auth system production ready +""" +from collections import OrderedDict +import hmac + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant import auth, data_entry_flow +from homeassistant.core import callback + +USER_SCHEMA = vol.Schema({ + vol.Required('username'): str, +}) + + +CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +LEGACY_USER = 'homeassistant' + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +@auth.AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(auth.AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + DEFAULT_TITLE = 'Legacy API Password' + + async def async_credential_flow(self): + """Return a flow to login.""" + return LoginFlow(self) + + @callback + def async_validate_login(self, password): + """Helper to validate a username and password.""" + if not hasattr(self.hass, 'http'): + raise ValueError('http component is not loaded') + + if self.hass.http.api_password is None: + raise ValueError('http component is not configured using' + ' api_password') + + if not hmac.compare_digest(self.hass.http.api_password.encode('utf-8'), + password.encode('utf-8')): + raise InvalidAuthError + + async def async_get_or_create_credentials(self, flow_result): + """Return LEGACY_USER always.""" + for credential in await self.async_credentials(): + if credential.data['username'] == LEGACY_USER: + return credential + + return self.async_create_credentials({ + 'username': LEGACY_USER + }) + + async def async_user_meta_for_credentials(self, credentials): + """ + Set name as LEGACY_USER always. + + Will be used to populate info when creating a new user. + """ + return {'name': LEGACY_USER} + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider): + """Initialize the login flow.""" + self._auth_provider = auth_provider + + async def async_step_init(self, user_input=None): + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + self._auth_provider.async_validate_login( + user_input['password']) + except InvalidAuthError: + errors['base'] = 'invalid_auth' + + if not errors: + return self.async_create_entry( + title=self._auth_provider.name, + data={} + ) + + schema = OrderedDict() + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b108ac805e9..0a71c2887b1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -123,7 +123,6 @@ async def async_from_config_dict(config: Dict[str, Any], components.update(hass.config_entries.async_domains()) # setup components - # pylint: disable=not-an-iterable res = await core_components.async_setup(hass, config) if not res: _LOGGER.error("Home Assistant core failed to initialize. " diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 25e303cbe85..f81d2ef1037 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -154,7 +154,6 @@ def async_setup(hass, config): return True -# pylint: disable=no-self-use class AlarmControlPanel(Entity): """An abstract class for alarm control devices.""" diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 8a0dfefdc70..9f2a4176ed8 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability) + CONF_RETAIN, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -54,6 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), config.get(CONF_QOS), + config.get(CONF_RETAIN), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), @@ -66,9 +67,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" - def __init__(self, name, state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, code, availability_topic, - payload_available, payload_not_available): + def __init__(self, name, state_topic, command_topic, qos, retain, + payload_disarm, payload_arm_home, payload_arm_away, code, + availability_topic, payload_available, payload_not_available): """Init the MQTT Alarm Control Panel.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -77,6 +78,7 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): self._state_topic = state_topic self._command_topic = command_topic self._qos = qos + self._retain = retain self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away @@ -134,7 +136,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): if not self._validate_code(code, 'disarming'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_disarm, self._qos) + self.hass, self._command_topic, self._payload_disarm, self._qos, + self._retain) @asyncio.coroutine def async_alarm_arm_home(self, code=None): @@ -145,7 +148,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): if not self._validate_code(code, 'arming home'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_home, self._qos) + self.hass, self._command_topic, self._payload_arm_home, self._qos, + self._retain) @asyncio.coroutine def async_alarm_arm_away(self, code=None): @@ -156,7 +160,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): if not self._validate_code(code, 'arming away'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_away, self._qos) + self.hass, self._command_topic, self._payload_arm_away, self._qos, + self._retain) def _validate_code(self, code, state): """Validate given code.""" diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index c5c68f1af40..ff2d4adf30d 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -107,7 +107,6 @@ class _DisplayCategory(object): THERMOSTAT = "THERMOSTAT" # Indicates the endpoint is a television. - # pylint: disable=invalid-name TV = "TV" @@ -1474,9 +1473,6 @@ async def async_api_set_thermostat_mode(hass, config, request, entity): mode = mode if isinstance(mode, str) else mode['value'] operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) - # Work around a pylint false positive due to - # https://github.com/PyCQA/pylint/issues/1830 - # pylint: disable=stop-iteration-return ha_mode = next( (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), None diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index ae89e2fc3b6..b80a5716061 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -81,7 +81,6 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" - # pylint: disable=no-self-use hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index cd2c13ad292..fa58c9b0baa 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.1.7'] +REQUIREMENTS = ['pyarlo==0.1.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bbb_gpio.py b/homeassistant/components/bbb_gpio.py index 5d3954b4c87..f932f239969 100644 --- a/homeassistant/components/bbb_gpio.py +++ b/homeassistant/components/bbb_gpio.py @@ -16,7 +16,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'bbb_gpio' -# pylint: disable=no-member def setup(hass, config): """Set up the BeagleBone Black GPIO component.""" # pylint: disable=import-error @@ -34,41 +33,39 @@ def setup(hass, config): return True -# noqa: F821 - def setup_output(pin): """Set up a GPIO as output.""" - # pylint: disable=import-error,undefined-variable + # pylint: disable=import-error import Adafruit_BBIO.GPIO as GPIO GPIO.setup(pin, GPIO.OUT) def setup_input(pin, pull_mode): """Set up a GPIO as input.""" - # pylint: disable=import-error,undefined-variable + # pylint: disable=import-error import Adafruit_BBIO.GPIO as GPIO - GPIO.setup(pin, GPIO.IN, # noqa: F821 - GPIO.PUD_DOWN if pull_mode == 'DOWN' # noqa: F821 - else GPIO.PUD_UP) # noqa: F821 + GPIO.setup(pin, GPIO.IN, + GPIO.PUD_DOWN if pull_mode == 'DOWN' + else GPIO.PUD_UP) def write_output(pin, value): """Write a value to a GPIO.""" - # pylint: disable=import-error,undefined-variable + # pylint: disable=import-error import Adafruit_BBIO.GPIO as GPIO GPIO.output(pin, value) def read_input(pin): """Read a value from a GPIO.""" - # pylint: disable=import-error,undefined-variable + # pylint: disable=import-error import Adafruit_BBIO.GPIO as GPIO return GPIO.input(pin) is GPIO.HIGH def edge_detect(pin, event_callback, bounce): """Add detection for RISING and FALLING events.""" - # pylint: disable=import-error,undefined-variable + # pylint: disable=import-error import Adafruit_BBIO.GPIO as GPIO GPIO.add_event_detect( pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index d72211d5ad1..26878044fe2 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -67,7 +67,6 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -# pylint: disable=no-self-use class BinarySensorDevice(Entity): """Represent a binary sensor.""" diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py index e214610f46d..308298d1bcd 100644 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -124,11 +124,11 @@ class BMWConnectedDriveSensor(BinarySensorDevice): result['check_control_messages'] = check_control_messages elif self._attribute == 'charging_status': result['charging_status'] = vehicle_state.charging_status.value - # pylint: disable=W0212 + # pylint: disable=protected-access result['last_charging_end_result'] = \ vehicle_state._attributes['lastChargingEndResult'] if self._attribute == 'connection_status': - # pylint: disable=W0212 + # pylint: disable=protected-access result['connection_status'] = \ vehicle_state._attributes['connectionStatus'] @@ -166,7 +166,7 @@ class BMWConnectedDriveSensor(BinarySensorDevice): # device class plug: On means device is plugged in, # Off means device is unplugged if self._attribute == 'connection_status': - # pylint: disable=W0212 + # pylint: disable=protected-access self._state = (vehicle_state._attributes['connectionStatus'] == 'CONNECTED') diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py index 140c84358c7..1eb86d4eb82 100644 --- a/homeassistant/components/binary_sensor/digital_ocean.py +++ b/homeassistant/components/binary_sensor/digital_ocean.py @@ -14,7 +14,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) + ATTR_REGION, ATTR_VCPUS, CONF_ATTRIBUTION, DATA_DIGITAL_OCEAN) +from homeassistant.const import ATTR_ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -75,6 +76,7 @@ class DigitalOceanBinarySensor(BinarySensorDevice): def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/binary_sensor/flic.py b/homeassistant/components/binary_sensor/flic.py index 170f1818a0e..baf1d469b28 100644 --- a/homeassistant/components/binary_sensor/flic.py +++ b/homeassistant/components/binary_sensor/flic.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -REQUIREMENTS = ['https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4'] +REQUIREMENTS = ['pyflic-homeassistant==0.4.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/gc100.py b/homeassistant/components/binary_sensor/gc100.py index 767be2874e6..515d7e7123d 100644 --- a/homeassistant/components/binary_sensor/gc100.py +++ b/homeassistant/components/binary_sensor/gc100.py @@ -39,7 +39,6 @@ class GC100BinarySensor(BinarySensorDevice): def __init__(self, name, port_addr, gc100): """Initialize the GC100 binary sensor.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index a80e4db747d..deaa118f51c 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -8,7 +8,7 @@ https://home-assistant.io/components/binary_sensor.isy994/ import asyncio import logging from datetime import timedelta -from typing import Callable # noqa +from typing import Callable from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 21443021193..abb19129d52 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -29,7 +29,8 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice): +class MySensorsBinarySensor( + mysensors.device.MySensorsEntity, BinarySensorDevice): """Representation of a MySensors Binary Sensor child node.""" @property diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 9da352e1268..31460c1eedc 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -31,12 +31,10 @@ CAMERA_BINARY_TYPES = { STRUCTURE_BINARY_TYPES = { 'away': None, - # 'security_state', # pending python-nest update } STRUCTURE_BINARY_STATE_MAP = { 'away': {'away': True, 'home': False}, - 'security_state': {'deter': True, 'ok': False}, } _BINARY_TYPES_DEPRECATED = [ @@ -135,7 +133,7 @@ class NestBinarySensor(NestSensorDevice, BinarySensorDevice): value = getattr(self.device, self.variable) if self.variable in STRUCTURE_BINARY_TYPES: self._state = bool(STRUCTURE_BINARY_STATE_MAP - [self.variable][value]) + [self.variable].get(value)) else: self._state = bool(value) diff --git a/homeassistant/components/binary_sensor/rachio.py b/homeassistant/components/binary_sensor/rachio.py new file mode 100644 index 00000000000..cc3079c6e53 --- /dev/null +++ b/homeassistant/components/binary_sensor/rachio.py @@ -0,0 +1,127 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rachio/ +""" +from abc import abstractmethod +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_STATUS, + KEY_SUBTYPE, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + STATUS_OFFLINE, + STATUS_ONLINE, + SUBTYPE_OFFLINE, + SUBTYPE_ONLINE,) +from homeassistant.helpers.dispatcher import dispatcher_connect + +DEPENDENCIES = ['rachio'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Rachio binary sensors.""" + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioControllerOnlineBinarySensor(hass, controller)) + + add_devices(devices) + _LOGGER.info("%d Rachio binary sensor(s) added", len(devices)) + + +class RachioControllerBinarySensor(BinarySensorDevice): + """Represent a binary sensor that reflects a Rachio state.""" + + def __init__(self, hass, controller, poll=True): + """Set up a new Rachio controller binary sensor.""" + self._controller = controller + + if poll: + self._state = self._poll_update() + else: + self._state = None + + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def is_on(self) -> bool: + """Return whether the sensor has a 'true' value.""" + return self._state + + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + + # For this device + self._handle_update() + + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + pass + + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + pass + + +class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): + """Represent a binary sensor that reflects if the controller is online.""" + + def __init__(self, hass, controller): + """Set up a new Rachio controller online binary sensor.""" + super().__init__(hass, controller, poll=False) + self._state = self._poll_update(controller.init_data) + + @property + def name(self) -> str: + """Return the name of this sensor including the controller name.""" + return "{} online".format(self._controller.name) + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def icon(self) -> str: + """Return the name of an icon for this sensor.""" + return 'mdi:wifi-strength-4' if self.is_on\ + else 'mdi:wifi-strength-off-outline' + + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] + + if data[KEY_STATUS] == STATUS_ONLINE: + return True + elif data[KEY_STATUS] == STATUS_OFFLINE: + return False + else: + _LOGGER.warning('"%s" reported in unknown state "%s"', self.name, + data[KEY_STATUS]) + + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_ONLINE: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: + self._state = False + + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/rpi_gpio.py b/homeassistant/components/binary_sensor/rpi_gpio.py index e1e06ce57b9..4072f4ae234 100644 --- a/homeassistant/components/binary_sensor/rpi_gpio.py +++ b/homeassistant/components/binary_sensor/rpi_gpio.py @@ -58,7 +58,6 @@ class RPiGPIOBinarySensor(BinarySensorDevice): def __init__(self, name, port, pull_mode, bouncetime, invert_logic): """Initialize the RPi binary sensor.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port = port self._pull_mode = pull_mode diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index dcdd312ce81..6a53569798b 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.14.3'] +REQUIREMENTS = ['numpy==1.14.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py index d3c78597c70..e6eff0d9bb5 100644 --- a/homeassistant/components/binary_sensor/wemo.py +++ b/homeassistant/components/binary_sensor/wemo.py @@ -13,7 +13,6 @@ DEPENDENCIES = ['wemo'] _LOGGER = logging.getLogger(__name__) -# pylint: disable=too-many-function-args def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Register discovered WeMo binary sensors.""" import pywemo.discovery as discovery diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index da76530a36d..87893125e6f 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -89,9 +89,7 @@ class GoogleCalendarData(object): params['timeMin'] = start_date.isoformat('T') params['timeMax'] = end_date.isoformat('T') - # pylint: disable=no-member events = await hass.async_add_job(service.events) - # pylint: enable=no-member result = await hass.async_add_job(events.list(**params).execute) items = result.get('items', []) @@ -111,7 +109,7 @@ class GoogleCalendarData(object): service, params = self._prepare_query() params['timeMin'] = dt.now().isoformat('T') - events = service.events() # pylint: disable=no-member + events = service.events() result = events.list(**params).execute() items = result.get('items', []) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ebda09de20c..14550dab899 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -322,6 +322,7 @@ class Camera(Entity): except asyncio.CancelledError: _LOGGER.debug("Stream closed by frontend.") response = None + raise finally: if response is not None: diff --git a/homeassistant/components/camera/neato.py b/homeassistant/components/camera/neato.py index 689129e1067..3a8a137c1fe 100644 --- a/homeassistant/components/camera/neato.py +++ b/homeassistant/components/camera/neato.py @@ -10,12 +10,13 @@ from datetime import timedelta from homeassistant.components.camera import Camera from homeassistant.components.neato import ( NEATO_MAP_DATA, NEATO_ROBOTS, NEATO_LOGIN) -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=10) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Neato Camera.""" @@ -45,7 +46,6 @@ class NeatoCleaningMap(Camera): self.update() return self._image - @Throttle(timedelta(seconds=60)) def update(self): """Check the contents of the map list.""" self.neato.update_robots() diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 1984c21fadb..447f4e1e56a 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -233,6 +233,7 @@ class ProxyCamera(Camera): _LOGGER.debug("Stream closed by frontend.") req.close() response = None + raise finally: if response is not None: diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index cec04b52047..2a4d1526818 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -67,8 +67,6 @@ async def async_setup_platform(hass, config, async_add_devices, ] for cam in config.get(CONF_CAMERAS, []): - # https://github.com/PyCQA/pylint/issues/1830 - # pylint: disable=stop-iteration-return camera = next( (dc for dc in discovered_cameras if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) diff --git a/homeassistant/components/camera/xiaomi.py b/homeassistant/components/camera/xiaomi.py index c18a3649e7b..e80f4b7532a 100644 --- a/homeassistant/components/camera/xiaomi.py +++ b/homeassistant/components/camera/xiaomi.py @@ -104,27 +104,25 @@ class XiaomiCamera(Camera): dirs = [d for d in ftp.nlst() if '.' not in d] if not dirs: - if self._model == MODEL_YI: - _LOGGER.warning("There don't appear to be any uploaded videos") - return False - elif self._model == MODEL_XIAOFANG: - _LOGGER.warning("There don't appear to be any folders") - return False + _LOGGER.warning("There don't appear to be any folders") + return False - first_dir = dirs[-1] - try: - ftp.cwd(first_dir) - except error_perm as exc: - _LOGGER.error('Unable to find path: %s - %s', first_dir, exc) - return False + first_dir = dirs[-1] + try: + ftp.cwd(first_dir) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s - %s', first_dir, exc) + return False + if self._model == MODEL_XIAOFANG: dirs = [d for d in ftp.nlst() if '.' not in d] if not dirs: _LOGGER.warning("There don't appear to be any uploaded videos") return False - latest_dir = dirs[-1] - ftp.cwd(latest_dir) + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + videos = [v for v in ftp.nlst() if '.tmp' not in v] if not videos: _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index 93f526c2b96..b575a705f98 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -77,7 +77,7 @@ class YiCamera(Camera): """Retrieve the latest video file from the customized Yi FTP server.""" from aioftp import Client, StatusCodeError - ftp = Client() + ftp = Client(loop=self.hass.loop) try: await ftp.connect(self.host) await ftp.login(self.user, self.passwd) diff --git a/homeassistant/components/cast/.translations/cs.json b/homeassistant/components/cast/.translations/cs.json new file mode 100644 index 00000000000..82f063b365f --- /dev/null +++ b/homeassistant/components/cast/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.", + "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json new file mode 100644 index 00000000000..2572c3344eb --- /dev/null +++ b/homeassistant/components/cast/.translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Google Cast einrichten?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json new file mode 100644 index 00000000000..f59a1b43ef1 --- /dev/null +++ b/homeassistant/components/cast/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/it.json b/homeassistant/components/cast/.translations/it.json new file mode 100644 index 00000000000..21c8e60518e --- /dev/null +++ b/homeassistant/components/cast/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/lb.json b/homeassistant/components/cast/.translations/lb.json new file mode 100644 index 00000000000..f1daff83069 --- /dev/null +++ b/homeassistant/components/cast/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Google Cast konfigur\u00e9iert ginn?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nl.json b/homeassistant/components/cast/.translations/nl.json new file mode 100644 index 00000000000..91c428770f5 --- /dev/null +++ b/homeassistant/components/cast/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Google Cast instellen?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sl.json b/homeassistant/components/cast/.translations/sl.json new file mode 100644 index 00000000000..24a7215574d --- /dev/null +++ b/homeassistant/components/cast/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant/components/cast/.translations/zh-Hant.json new file mode 100644 index 00000000000..711ac320397 --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a47edc5af42..9584422e2b4 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -470,7 +470,6 @@ async def async_unload_entry(hass, entry): class ClimateDevice(Entity): """Representation of a climate device.""" - # pylint: disable=no-self-use @property def state(self): """Return the current state.""" diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 820e715b00d..10fd879e386 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -# pylint: disable=import-error, no-name-in-module +# pylint: disable=import-error class EQ3BTSmartThermostat(ClimateDevice): """Representation of an eQ-3 Bluetooth Smart thermostat.""" diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 030a76626c6..3f1d9a208ac 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -263,7 +263,6 @@ class GenericThermostat(ClimateDevice): @property def min_temp(self): """Return the minimum temperature.""" - # pylint: disable=no-member if self._min_temp: return self._min_temp @@ -273,7 +272,6 @@ class GenericThermostat(ClimateDevice): @property def max_temp(self): """Return the maximum temperature.""" - # pylint: disable=no-member if self._max_temp: return self._max_temp diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py index 19c033a319f..92e363228a8 100644 --- a/homeassistant/components/climate/heatmiser.py +++ b/homeassistant/components/climate/heatmiser.py @@ -34,7 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the heatmiser thermostat.""" from heatmiserV3 import heatmiser, connection diff --git a/homeassistant/components/climate/homekit_controller.py b/homeassistant/components/climate/homekit_controller.py new file mode 100644 index 00000000000..f9178c2e0d5 --- /dev/null +++ b/homeassistant/components/climate/homekit_controller.py @@ -0,0 +1,130 @@ +""" +Support for Homekit climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.homekit_controller/ +""" +import logging + +from homeassistant.components.homekit_controller import ( + HomeKitEntity, KNOWN_ACCESSORIES) +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, STATE_OFF, ATTR_TEMPERATURE + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + +# Map of Homekit operation modes to hass modes +MODE_HOMEKIT_TO_HASS = { + 0: STATE_OFF, + 1: STATE_HEAT, + 2: STATE_COOL, +} + +# Map of hass operation modes to homekit modes +MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit climate.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitClimateDevice(accessory, discovery_info)], True) + + +class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): + """Representation of a Homekit climate device.""" + + def __init__(self, *args): + """Initialise the device.""" + super().__init__(*args) + self._state = None + self._current_mode = None + self._valid_modes = [] + self._current_temp = None + self._target_temp = None + + def update_characteristics(self, characteristics): + """Synchronise device state with Home Assistant.""" + # pylint: disable=import-error + from homekit import CharacteristicsTypes as ctypes + + for characteristic in characteristics: + ctype = characteristic['type'] + if ctype == ctypes.HEATING_COOLING_CURRENT: + self._state = MODE_HOMEKIT_TO_HASS.get( + characteristic['value']) + if ctype == ctypes.HEATING_COOLING_TARGET: + self._chars['target_mode'] = characteristic['iid'] + self._features |= SUPPORT_OPERATION_MODE + self._current_mode = MODE_HOMEKIT_TO_HASS.get( + characteristic['value']) + self._valid_modes = [MODE_HOMEKIT_TO_HASS.get( + mode) for mode in characteristic['valid-values']] + elif ctype == ctypes.TEMPERATURE_CURRENT: + self._current_temp = characteristic['value'] + elif ctype == ctypes.TEMPERATURE_TARGET: + self._chars['target_temp'] = characteristic['iid'] + self._features |= SUPPORT_TARGET_TEMPERATURE + self._target_temp = characteristic['value'] + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + characteristics = [{'aid': self._aid, + 'iid': self._chars['target_temp'], + 'value': temp}] + self.put_characteristics(characteristics) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['target_mode'], + 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] + self.put_characteristics(characteristics) + + @property + def state(self): + """Return the current state.""" + # If the device reports its operating mode as off, it sometimes doesn't + # report a new state. + if self._current_mode == STATE_OFF: + return STATE_OFF + + if self._state == STATE_OFF and self._current_mode != STATE_OFF: + return STATE_IDLE + return self._state + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_mode + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._valid_modes + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._features + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 5397daeb784..fbe5460979b 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -129,6 +129,9 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT climate devices.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + template_keys = ( CONF_POWER_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, @@ -635,11 +638,9 @@ class MqttClimate(MqttAvailability, ClimateDevice): @property def min_temp(self): """Return the minimum temperature.""" - # pylint: disable=no-member return self._min_temp @property def max_temp(self): """Return the maximum temperature.""" - # pylint: disable=no-member return self._max_temp diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 9fab56c61ac..a2043c2434b 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -26,9 +26,8 @@ DICT_MYS_TO_HA = { 'Off': STATE_OFF, } -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_FAN_MODE | - SUPPORT_OPERATION_MODE) +FAN_LIST = ['Auto', 'Min', 'Normal', 'Max'] +OPERATION_LIST = [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT] async def async_setup_platform( @@ -39,13 +38,24 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): +class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + features = SUPPORT_OPERATION_MODE + set_req = self.gateway.const.SetReq + if set_req.V_HVAC_SPEED in self._values: + features = features | SUPPORT_FAN_MODE + if (set_req.V_HVAC_SETPOINT_COOL in self._values and + set_req.V_HVAC_SETPOINT_HEAT in self._values): + features = ( + features | SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) + else: + features = features | SUPPORT_TARGET_TEMPERATURE + return features @property def assumed_state(self): @@ -103,7 +113,7 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): @property def operation_list(self): """List of available operation modes.""" - return [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT] + return OPERATION_LIST @property def current_fan_mode(self): @@ -113,7 +123,7 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): @property def fan_list(self): """List of available fan modes.""" - return ['Auto', 'Min', 'Normal', 'Max'] + return FAN_LIST async def async_set_temperature(self, **kwargs): """Set new target temperature.""" diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 1eec9c82f3c..52c544256b6 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -5,15 +5,15 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.zwave/ """ # Because we do not compile openzwave on CI -# pylint: disable=import-error import logging from homeassistant.components.climate import ( - DOMAIN, ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, + DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -32,6 +32,15 @@ DEVICE_MAPPINGS = { REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 } +STATE_MAPPINGS = { + 'Off': STATE_OFF, + 'Heat': STATE_HEAT, + 'Heat Mode': STATE_HEAT, + 'Heat (Default)': STATE_HEAT, + 'Cool': STATE_COOL, + 'Auto': STATE_AUTO, +} + def get_device(hass, values, **kwargs): """Create Z-Wave entity device.""" @@ -49,6 +58,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self._current_temperature = None self._current_operation = None self._operation_list = None + self._operation_mapping = None self._operating_state = None self._current_fan_mode = None self._fan_list = None @@ -87,10 +97,21 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): """Handle the data changes for node values.""" # Operation Mode if self.values.mode: - self._current_operation = self.values.mode.data + self._operation_list = [] + self._operation_mapping = {} operation_list = self.values.mode.data_items if operation_list: - self._operation_list = list(operation_list) + for mode in operation_list: + ha_mode = STATE_MAPPINGS.get(mode) + if ha_mode and ha_mode not in self._operation_mapping: + self._operation_mapping[ha_mode] = mode + self._operation_list.append(ha_mode) + continue + self._operation_list.append(mode) + current_mode = self.values.mode.data + self._current_operation = next( + (key for key, value in self._operation_mapping.items() + if value == current_mode), current_mode) _LOGGER.debug("self._operation_list=%s", self._operation_list) _LOGGER.debug("self._current_operation=%s", self._current_operation) @@ -206,7 +227,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): def set_operation_mode(self, operation_mode): """Set new target operation mode.""" if self.values.mode: - self.values.mode.data = operation_mode + self.values.mode.data = self._operation_mapping.get( + operation_mode, operation_mode) def set_swing_mode(self, swing_mode): """Set new target swing mode.""" diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index e4c8f5634cf..f5d3d798e2e 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -198,7 +198,6 @@ async def async_setup(hass, config): class CoverDevice(Entity): """Representation a cover.""" - # pylint: disable=no-self-use @property def current_cover_position(self): """Return current position of cover. diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 70e681f1120..b1533bd68c8 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -24,7 +24,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DemoCover(CoverDevice): """Representation of a demo cover.""" - # pylint: disable=no-self-use def __init__(self, hass, name, position=None, tilt_position=None, device_class=None, supported_features=None): """Initialize the cover.""" diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index c19aa69c8f0..70f69568109 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -73,7 +73,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class GaradgetCover(CoverDevice): """Representation of a Garadget cover.""" - # pylint: disable=no-self-use def __init__(self, hass, args): """Initialize the cover.""" self.particle_url = 'https://api.particle.io' diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 743a36d41d5..0ccfe267989 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.cover import CoverDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index 3f8eb054710..c815cf44df2 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -17,7 +17,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): +class MySensorsCover(mysensors.device.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index 028a7a0c9fc..fe6c7763cc7 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -72,7 +72,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class OpenGarageCover(CoverDevice): """Representation of a OpenGarage cover.""" - # pylint: disable=no-self-use def __init__(self, hass, args): """Initialize the cover.""" self.opengarage_url = 'http://{}:{}'.format( diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 093ccd43473..7f7a3a11644 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -21,6 +21,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _id = shade.object_id() + shade.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkCoverDevice(shade, hass)]) + for shade in pywink.get_shade_groups(): + _id = shade.object_id() + shade.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkCoverDevice(shade, hass)]) for door in pywink.get_garage_doors(): _id = door.object_id() + door.name() if _id not in hass.data[DOMAIN]['unique_ids']: diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 6f4a11684bd..c29c11c5b6b 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -42,7 +42,6 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): def __init__(self, hass, values, invert_buttons): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) - # pylint: disable=no-member self._network = hass.data[zwave.const.DATA_NETWORK] self._open_id = None self._close_id = None diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json index 0721cac3321..1588766e406 100644 --- a/homeassistant/components/deconz/.translations/cs.json +++ b/homeassistant/components/deconz/.translations/cs.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel" + "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", + "allow_deconz_groups": "Povolit import skupin deCONZ " }, "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" } diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 9d3dc9e6e62..b09b7e15b31 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -19,8 +19,14 @@ "link": { "description": "Entsperren Sie Ihr deCONZ-Gateway, um sich bei Home Assistant zu registrieren. \n\n 1. Gehen Sie zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccken Sie die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" + }, + "options": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + } } }, - "title": "deCONZ" + "title": "deCONZ Zigbee Gateway" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 46190d23926..3de7de9ddb3 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren" + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" }, "title": "Extra Konfiguratiouns Optiounen fir deCONZ" } diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 90d13bb39b4..6f3fa2ec9a4 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -19,6 +19,13 @@ "link": { "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "title": "Extra configuratieopties voor deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index 59c5577c96b..bc7a2cbd861 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev" + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" }, "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" } diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 17cbe87f1e8..5cd1a14d499 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -22,7 +22,8 @@ }, "options": { "data": { - "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668" + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" }, "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 850645225d0..4fa89f8cfd3 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -22,7 +22,7 @@ from .const import ( CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==38'] +REQUIREMENTS = ['pydeconz==39'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 27fb6987f8c..b67d32508be 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -163,9 +163,6 @@ class DeconzFlowHandler(data_entry_flow.FlowHandler): if CONF_API_KEY not in import_config: return await self.async_step_link() - self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = True - self.deconz_config[CONF_ALLOW_DECONZ_GROUPS] = True - return self.async_create_entry( - title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], - data=self.deconz_config - ) + user_input = {CONF_ALLOW_CLIP_SENSOR: True, + CONF_ALLOW_DECONZ_GROUPS: True} + return await self.async_step_options(user_input=user_input) diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index 0978ba99593..c13f622c5bf 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -50,7 +50,6 @@ class CiscoDeviceScanner(DeviceScanner): self.success_init = self._update_info() _LOGGER.info('cisco_ios scanner initialized') - # pylint: disable=no-self-use def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device.""" return None diff --git a/homeassistant/components/device_tracker/freebox.py b/homeassistant/components/device_tracker/freebox.py index 67957ca99b9..b278c421925 100644 --- a/homeassistant/components/device_tracker/freebox.py +++ b/homeassistant/components/device_tracker/freebox.py @@ -22,7 +22,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import ( CONF_HOST, CONF_PORT) -REQUIREMENTS = ['aiofreepybox==0.0.3'] +REQUIREMENTS = ['aiofreepybox==0.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index 68ea9ac88ae..6336ba51d23 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/device_tracker.gpslogger/ import logging from hmac import compare_digest -from aiohttp.web import Request, HTTPUnauthorized # NOQA +from aiohttp.web import Request, HTTPUnauthorized import voluptuous as vol import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 8837b628b32..bf3916f3abe 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -61,7 +61,6 @@ class LinksysAPDeviceScanner(DeviceScanner): return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """ Return the name (if known) of the device. diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index a6a67749f76..e9a7efeb64a 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==1.0.5'] +REQUIREMENTS = ['librouteros==2.1.0'] MTK_DEFAULT_API_PORT = '8728' diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index b0d29bf0566..49d3f3207ba 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -23,13 +23,13 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): id(device.gateway), device.node_id, device.child_id, device.value_type) async_dispatcher_connect( - hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + hass, mysensors.const.SIGNAL_CALLBACK.format(*dev_id), device.async_update_callback) return True -class MySensorsDeviceScanner(mysensors.MySensorsDevice): +class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" def __init__(self, async_see, *args): diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3d57cb108e2..6a849d0b05a 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -74,8 +74,6 @@ class SnmpScanner(DeviceScanner): return [client['mac'] for client in self.last_results if client.get('mac')] - # Suppressing no-self-use warning - # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" # We have no names @@ -106,7 +104,6 @@ class SnmpScanner(DeviceScanner): if errindication: _LOGGER.error("SNMPLIB error: %s", errindication) return - # pylint: disable=no-member if errstatus: _LOGGER.error("SNMP error: %s at %s", errstatus.prettyPrint(), errindex and restable[int(errindex) - 1][0] or '?') diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 6c5fb697c07..5266b9c6f57 100644 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -68,7 +68,6 @@ class TplinkDeviceScanner(DeviceScanner): self._update_info() return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None @@ -103,7 +102,6 @@ class Tplink2DeviceScanner(TplinkDeviceScanner): self._update_info() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return self.last_results.get(device) @@ -164,7 +162,6 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): self._log_out() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device. @@ -273,7 +270,6 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): self._update_info() return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """Get the name of the wireless device.""" return None @@ -349,7 +345,6 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): self._update_info() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py index bd03fb01975..a0f50842649 100644 --- a/homeassistant/components/digital_ocean.py +++ b/homeassistant/components/digital_ocean.py @@ -27,6 +27,7 @@ ATTR_MEMORY = 'memory' ATTR_REGION = 'region' ATTR_VCPUS = 'vcpus' +CONF_ATTRIBUTION = 'Data provided by Digital Ocean' CONF_DROPLETS = 'droplets' DATA_DIGITAL_OCEAN = 'data_do' diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index d7041865892..78b891bae92 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.4.1'] +REQUIREMENTS = ['netdisco==1.5.0'] DOMAIN = 'discovery' diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 22348dcc297..96f094b527d 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -105,7 +105,6 @@ def setup(hass, config): Will automatically load thermostat and sensor components to support devices discovered on the network. """ - # pylint: disable=import-error global NETWORK if 'ecobee' in _CONFIGURING: diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index fd7f7147fdb..6988e20fb5f 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -91,9 +91,11 @@ def setup(hass, yaml_config): server_port=config.listen_port, api_password=None, ssl_certificate=None, + ssl_peer_certificate=None, ssl_key=None, cors_origins=None, use_x_forwarded_for=False, + trusted_proxies=[], trusted_networks=[], login_threshold=0, is_ban_enabled=False diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 54a77af5cfb..0b9c8edd411 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180625.0'] +REQUIREMENTS = ['home-assistant-frontend==20180704.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -200,8 +200,8 @@ def add_manifest_json_key(key, val): async def async_setup(hass, config): """Set up the serving of the frontend.""" - if list(hass.auth.async_auth_providers): - client = await hass.auth.async_create_client( + if hass.auth.active: + client = await hass.auth.async_get_or_create_client( 'Home Assistant Frontend', redirect_uris=['/'], no_secret=True, diff --git a/homeassistant/components/gc100.py b/homeassistant/components/gc100.py index bc627d44417..25bcb5b0f79 100644 --- a/homeassistant/components/gc100.py +++ b/homeassistant/components/gc100.py @@ -31,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=no-member, import-self +# pylint: disable=no-member def setup(hass, base_config): """Set up the gc100 component.""" import gc100 diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index b41d4ea33a2..203b1a94b7f 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -197,7 +197,7 @@ def setup_services(hass, track_new_found_calendars, calendar_service): def _scan_for_calendars(service): """Scan for new calendars.""" service = calendar_service.get() - cal_list = service.calendarList() # pylint: disable=no-member + cal_list = service.calendarList() calendars = cal_list.list().execute()['items'] for calendar in calendars: calendar['track'] = track_new_found_calendars diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 1c6d11a7c99..567a6d84233 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -13,9 +13,8 @@ import async_timeout import voluptuous as vol # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports -from homeassistant.core import HomeAssistant # NOQA -from typing import Dict, Any # NOQA +from homeassistant.core import HomeAssistant +from typing import Dict, Any from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py index a21dd0e6738..e80b2282066 100644 --- a/homeassistant/components/google_assistant/auth.py +++ b/homeassistant/components/google_assistant/auth.py @@ -3,12 +3,11 @@ import logging # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Any # NOQA +from aiohttp.web import Request, Response +from typing import Dict, Any -from homeassistant.core import HomeAssistant # NOQA +from homeassistant.core import HomeAssistant from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( HTTP_BAD_REQUEST, diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 0ea5f7d9fa4..65079a1a26e 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -7,10 +7,10 @@ https://home-assistant.io/components/google_assistant/ import logging from aiohttp.hdrs import AUTHORIZATION -from aiohttp.web import Request, Response # NOQA +from aiohttp.web import Request, Response # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# pylint: disable=unused-import from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback # NOQA from homeassistant.helpers.entity import Entity # NOQA diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 27d993aee76..f20d4f747cc 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -4,7 +4,7 @@ from itertools import product import logging # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# pylint: disable=unused-import # if False: from aiohttp.web import Request, Response # NOQA from typing import Dict, Tuple, Any, Optional # NOQA diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 34372b8b6a8..cb9387fb2c0 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -107,8 +107,8 @@ def get_accessory(hass, driver, state, aid, config): a_type = 'Thermostat' elif state.domain == 'cover': - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) device_class = state.attributes.get(ATTR_DEVICE_CLASS) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if device_class == 'garage' and \ features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): @@ -134,8 +134,8 @@ def get_accessory(hass, driver, state, aid, config): a_type = 'MediaPlayer' elif state.domain == 'sensor': - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) device_class = state.attributes.get(ATTR_DEVICE_CLASS) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if device_class == DEVICE_CLASS_TEMPERATURE or \ unit in (TEMP_CELSIUS, TEMP_FAHRENHEIT): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 1b0d5ce1be4..d4e6d48c29f 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -8,7 +8,8 @@ from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER -from homeassistant.const import __version__ +from homeassistant.const import ( + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL) from homeassistant.core import callback as ha_callback from homeassistant.core import split_entity_id from homeassistant.helpers.event import ( @@ -16,10 +17,11 @@ from homeassistant.helpers.event import ( from homeassistant.util import dt as dt_util from .const import ( - BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, - DEBOUNCE_TIMEOUT, MANUFACTURER) + BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_BATTERY_LEVEL, + CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, DEBOUNCE_TIMEOUT, + MANUFACTURER, SERV_BATTERY_SERVICE) from .util import ( - show_setup_message, dismiss_setup_message) + convert_to_float, show_setup_message, dismiss_setup_message) _LOGGER = logging.getLogger(__name__) @@ -67,6 +69,23 @@ class HomeAccessory(Accessory): self.entity_id = entity_id self.hass = hass self.debounce = {} + self._support_battery_level = False + self._support_battery_charging = True + + """Add battery service if available""" + battery_level = self.hass.states.get(self.entity_id).attributes \ + .get(ATTR_BATTERY_LEVEL) + if battery_level is None: + return + _LOGGER.debug('%s: Found battery level attribute', self.entity_id) + self._support_battery_level = True + serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE) + self._char_battery = serv_battery.configure_char( + CHAR_BATTERY_LEVEL, value=0) + self._char_charging = serv_battery.configure_char( + CHAR_CHARGING_STATE, value=2) + self._char_low_battery = serv_battery.configure_char( + CHAR_STATUS_LOW_BATTERY, value=0) async def run(self): """Method called by accessory after driver is started. @@ -85,8 +104,32 @@ class HomeAccessory(Accessory): _LOGGER.debug('New_state: %s', new_state) if new_state is None: return + if self._support_battery_level: + self.hass.async_add_job(self.update_battery, new_state) self.hass.async_add_job(self.update_state, new_state) + def update_battery(self, new_state): + """Update battery service if available. + + Only call this function if self._support_battery_level is True. + """ + battery_level = convert_to_float( + new_state.attributes.get(ATTR_BATTERY_LEVEL)) + self._char_battery.set_value(battery_level) + self._char_low_battery.set_value(battery_level < 20) + _LOGGER.debug('%s: Updated battery level to %d', self.entity_id, + battery_level) + if not self._support_battery_charging: + return + charging = new_state.attributes.get(ATTR_BATTERY_CHARGING) + if charging is None: + self._support_battery_charging = False + return + hk_charging = 1 if charging is True else 0 + self._char_charging.set_value(hk_charging) + _LOGGER.debug('%s: Updated battery charging to %d', self.entity_id, + hk_charging) + def update_state(self, new_state): """Method called on state change to update HomeKit value. diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index dec6353850e..33d2c0bfb85 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -38,6 +38,7 @@ TYPE_SWITCH = 'switch' # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' +SERV_BATTERY_SERVICE = 'BatteryService' SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' SERV_CONTACT_SENSOR = 'ContactSensor' @@ -62,11 +63,13 @@ SERV_WINDOW_COVERING = 'WindowCovering' CHAR_ACTIVE = 'Active' CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' CHAR_AIR_QUALITY = 'AirQuality' +CHAR_BATTERY_LEVEL = 'BatteryLevel' CHAR_BRIGHTNESS = 'Brightness' CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' +CHAR_CHARGING_STATE = 'ChargingState' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' @@ -96,6 +99,7 @@ CHAR_ROTATION_DIRECTION = 'RotationDirection' CHAR_SATURATION = 'Saturation' CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_STATUS_LOW_BATTERY = 'StatusLowBattery' CHAR_SWING_MODE = 'SwingMode' CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index bf0d4da6a59..aa44b11fefb 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -57,9 +57,6 @@ class Fan(HomeAccessory): def set_state(self, value): """Set state if call came from HomeKit.""" - if self._state == value: - return - _LOGGER.debug('%s: Set state to %d', self.entity_id, value) self._flag[CHAR_ACTIVE] = True service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index bbf8b3f17cb..a7d36720cab 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -5,8 +5,10 @@ from pyhap.const import CATEGORY_ALARM_SYSTEM from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, STATE_ALARM_DISARMED) + ATTR_ENTITY_ID, ATTR_CODE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, + STATE_ALARM_DISARMED) from . import TYPES from .accessories import HomeAccessory @@ -22,10 +24,11 @@ HASS_TO_HOMEKIT = {STATE_ALARM_ARMED_HOME: 0, STATE_ALARM_DISARMED: 3, STATE_ALARM_TRIGGERED: 4} HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} -STATE_TO_SERVICE = {STATE_ALARM_ARMED_HOME: 'alarm_arm_home', - STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', - STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night', - STATE_ALARM_DISARMED: 'alarm_disarm'} +STATE_TO_SERVICE = { + STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, + STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, + STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, + STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM} @TYPES.register('SecuritySystem') diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index c8bf8c7ad7c..a5724057eee 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -3,7 +3,7 @@ import logging from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH -from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) from homeassistant.core import split_entity_id @@ -37,7 +37,7 @@ class Outlet(HomeAccessory): self.flag_target_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(SWITCH, service, params) + self.hass.services.call(DOMAIN, service, params) def update_state(self, new_state): """Update switch state after state changed.""" diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 0883c5a3cc8..34fdcb2c035 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -23,6 +23,7 @@ HOMEKIT_DIR = '.homekit' HOMEKIT_ACCESSORY_DISPATCH = { 'lightbulb': 'light', 'outlet': 'switch', + 'thermostat': 'climate', } KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) @@ -219,8 +220,12 @@ class HomeKitEntity(Entity): """Synchronise a HomeKit device state with Home Assistant.""" raise NotImplementedError + def put_characteristics(self, characteristics): + """Control a HomeKit device state from Home Assistant.""" + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + -# pylint: too-many-function-args def setup(hass, config): """Set up for Homekit devices.""" def discovery_dispatch(service, discovery_info): diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 29303b551e2..1428bbd3e56 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.43'] +REQUIREMENTS = ['pyhomematic==0.1.44'] _LOGGER = logging.getLogger(__name__) @@ -148,6 +148,7 @@ CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' +CONF_JSONPORT = 'jsonport' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' CONF_PRIMARY = 'primary' @@ -155,6 +156,7 @@ CONF_PRIMARY = 'primary' DEFAULT_LOCAL_IP = '0.0.0.0' DEFAULT_LOCAL_PORT = 0 DEFAULT_RESOLVENAMES = False +DEFAULT_JSONPORT = 80 DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' @@ -178,6 +180,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), + vol.Optional(CONF_JSONPORT, default=DEFAULT_JSONPORT): cv.port, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, @@ -299,6 +302,7 @@ def setup(hass, config): 'port': rconfig.get(CONF_PORT), 'path': rconfig.get(CONF_PATH), 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'jsonport': rconfig.get(CONF_JSONPORT), 'username': rconfig.get(CONF_USERNAME), 'password': rconfig.get(CONF_PASSWORD), 'callbackip': rconfig.get(CONF_CALLBACK_IP), diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 17906157a6e..37a6805dfb5 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -40,33 +40,29 @@ CONF_SERVER_HOST = 'server_host' CONF_SERVER_PORT = 'server_port' CONF_BASE_URL = 'base_url' CONF_SSL_CERTIFICATE = 'ssl_certificate' +CONF_SSL_PEER_CERTIFICATE = 'ssl_peer_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' CONF_USE_X_FORWARDED_FOR = 'use_x_forwarded_for' +CONF_TRUSTED_PROXIES = 'trusted_proxies' CONF_TRUSTED_NETWORKS = 'trusted_networks' CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold' CONF_IP_BAN_ENABLED = 'ip_ban_enabled' # TLS configuration follows the best-practice guidelines specified here: # https://wiki.mozilla.org/Security/Server_Side_TLS -# Intermediate guidelines are followed. -SSL_VERSION = ssl.PROTOCOL_SSLv23 -SSL_OPTS = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 +# Modern guidelines are followed. +SSL_VERSION = ssl.PROTOCOL_TLS # pylint: disable=no-member +SSL_OPTS = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | \ + ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | \ + ssl.OP_CIPHER_SERVER_PREFERENCE if hasattr(ssl, 'OP_NO_COMPRESSION'): SSL_OPTS |= ssl.OP_NO_COMPRESSION -CIPHERS = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" \ +CIPHERS = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" \ + "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" \ "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" \ - "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" \ - "DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:" \ - "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:" \ - "ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:" \ - "ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:" \ - "ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:" \ - "DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:" \ - "DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:" \ - "ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:" \ - "AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:" \ - "AES256-SHA:DES-CBC3-SHA:!DSS" + "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" \ + "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" _LOGGER = logging.getLogger(__name__) @@ -80,10 +76,13 @@ HTTP_SCHEMA = vol.Schema({ vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, + vol.Optional(CONF_TRUSTED_PROXIES, default=[]): + vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, @@ -108,9 +107,11 @@ async def async_setup(hass, config): server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] + trusted_proxies = conf[CONF_TRUSTED_PROXIES] trusted_networks = conf[CONF_TRUSTED_NETWORKS] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] @@ -125,9 +126,11 @@ async def async_setup(hass, config): server_port=server_port, api_password=api_password, ssl_certificate=ssl_certificate, + ssl_peer_certificate=ssl_peer_certificate, ssl_key=ssl_key, cors_origins=cors_origins, use_x_forwarded_for=use_x_forwarded_for, + trusted_proxies=trusted_proxies, trusted_networks=trusted_networks, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled @@ -166,21 +169,37 @@ async def async_setup(hass, config): class HomeAssistantHTTP(object): """HTTP server for Home Assistant.""" - def __init__(self, hass, api_password, ssl_certificate, + def __init__(self, hass, api_password, + ssl_certificate, ssl_peer_certificate, ssl_key, server_host, server_port, cors_origins, - use_x_forwarded_for, trusted_networks, + use_x_forwarded_for, trusted_proxies, trusted_networks, login_threshold, is_ban_enabled): """Initialize the HTTP Home Assistant server.""" app = self.app = web.Application( middlewares=[staticresource_middleware]) # This order matters - setup_real_ip(app, use_x_forwarded_for) + setup_real_ip(app, use_x_forwarded_for, trusted_proxies) if is_ban_enabled: setup_bans(hass, app, login_threshold) - setup_auth(app, trusted_networks, api_password) + if hass.auth.active: + if hass.auth.support_legacy: + _LOGGER.warning("Experimental auth api enabled and " + "legacy_api_password support enabled. Please " + "use access_token instead api_password, " + "although you can still use legacy " + "api_password") + else: + _LOGGER.warning("Experimental auth api enabled. Please use " + "access_token instead api_password.") + elif api_password is None: + _LOGGER.warning("You have been advised to set http.api_password.") + + setup_auth(app, trusted_networks, hass.auth.active, + support_legacy=hass.auth.support_legacy, + api_password=api_password) if cors_origins: setup_cors(app, cors_origins) @@ -190,6 +209,7 @@ class HomeAssistantHTTP(object): self.hass = hass self.api_password = api_password self.ssl_certificate = ssl_certificate + self.ssl_peer_certificate = ssl_peer_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port @@ -287,8 +307,12 @@ class HomeAssistantHTTP(object): except OSError as error: _LOGGER.error("Could not read SSL certificate from %s: %s", self.ssl_certificate, error) - context = None return + + if self.ssl_peer_certificate: + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(cafile=self.ssl_peer_certificate) + else: context = None diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index c4723abccee..a232d9295a4 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -17,37 +17,44 @@ _LOGGER = logging.getLogger(__name__) @callback -def setup_auth(app, trusted_networks, api_password): +def setup_auth(app, trusted_networks, use_auth, + support_legacy=False, api_password=None): """Create auth middleware for the app.""" @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if api_password is None: - request[KEY_AUTHENTICATED] = True - return await handler(request) - - # Check authentication authenticated = False - if (HTTP_HEADER_HA_AUTH in request.headers and - hmac.compare_digest( - api_password.encode('utf-8'), - request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): + 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.') + + legacy_auth = (not use_auth or support_legacy) and api_password + if (hdrs.AUTHORIZATION in request.headers and + await async_validate_auth_header( + request, api_password if legacy_auth else None)): + # it included both use_auth and api_password Basic auth + authenticated = True + + elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and + hmac.compare_digest( + api_password.encode('utf-8'), + request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True - elif (DATA_API_PASSWORD in request.query and + elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True - elif (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header(api_password, request)): + elif _is_trusted_ip(request, trusted_networks): authenticated = True - elif _is_trusted_ip(request, trusted_networks): + elif not use_auth and api_password is None: + # If neither password nor auth_providers set, + # just always set authenticated=True authenticated = True request[KEY_AUTHENTICATED] = authenticated @@ -76,8 +83,12 @@ def validate_password(request, api_password): request.app['hass'].http.api_password.encode('utf-8')) -async def async_validate_auth_header(api_password, request): - """Test an authorization header if valid password.""" +async def async_validate_auth_header(request, api_password=None): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ if hdrs.AUTHORIZATION not in request.headers: return False @@ -88,7 +99,16 @@ async def async_validate_auth_header(api_password, request): # If no space in authorization header return False - if auth_type == 'Basic': + if auth_type == 'Bearer': + hass = request.app['hass'] + access_token = hass.auth.async_get_access_token(auth_val) + if access_token is None: + return False + + request['hass_user'] = access_token.refresh_token.user + return True + + elif auth_type == 'Basic' and api_password is not None: decoded = base64.b64decode(auth_val).decode('utf-8') try: username, password = decoded.split(':', 1) @@ -102,13 +122,5 @@ async def async_validate_auth_header(api_password, request): return hmac.compare_digest(api_password.encode('utf-8'), password.encode('utf-8')) - if auth_type != 'Bearer': + else: return False - - hass = request.app['hass'] - access_token = hass.auth.async_get_access_token(auth_val) - if access_token is None: - return False - - request['hass_user'] = access_token.refresh_token.user - return True diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index c394016a683..f8adc815fde 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -11,18 +11,25 @@ from .const import KEY_REAL_IP @callback -def setup_real_ip(app, use_x_forwarded_for): +def setup_real_ip(app, use_x_forwarded_for, trusted_proxies): """Create IP Ban middleware for the app.""" @middleware async def real_ip_middleware(request, handler): """Real IP middleware.""" - if (use_x_forwarded_for and - X_FORWARDED_FOR in request.headers): - request[KEY_REAL_IP] = ip_address( - request.headers.get(X_FORWARDED_FOR).split(',')[0]) - else: - request[KEY_REAL_IP] = \ - ip_address(request.transport.get_extra_info('peername')[0]) + connected_ip = ip_address( + request.transport.get_extra_info('peername')[0]) + request[KEY_REAL_IP] = connected_ip + + # Only use the XFF header if enabled, present, and from a trusted proxy + try: + if (use_x_forwarded_for and + X_FORWARDED_FOR in request.headers and + any(connected_ip in trusted_proxy + for trusted_proxy in trusted_proxies)): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(', ')[-1]) + except ValueError: + pass return await handler(request) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 3fbaf703d06..cd07ab6df69 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -18,7 +18,6 @@ class CachingStaticResource(StaticResource): filename = URL(request.match_info['filename']).path try: # PyLint is wrong about resolve not being a member. - # pylint: disable=no-member filepath = self._directory.joinpath(filename).resolve() if not self._follow_symlinks: filepath.relative_to(self._directory) diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index d466488e9fc..dc0968dc88a 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -24,6 +24,6 @@ "title": "Hub verbinden" } }, - "title": "Philips Hue Bridge" + "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index ea1e4fff1bf..b471dd1a0cd 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -24,6 +24,6 @@ "title": "\u0421\u0432\u044f\u0437\u044c \u0441 \u0445\u0430\u0431\u043e\u043c" } }, - "title": "\u0428\u043b\u044e\u0437 Philips Hue" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index d7a8dc7f730..8710b2561b0 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -124,24 +124,16 @@ class HueBridge(object): (group for group in self.api.groups.values() if group.name == group_name), None) - # The same scene name can exist in multiple groups. - # In this case, activate first scene that contains the - # the exact same light IDs as the group - scenes = [] - for scene in self.api.scenes.values(): - if scene.name == scene_name: - scenes.append(scene) - if len(scenes) == 1: - scene_id = scenes[0].id - else: - group_lights = sorted(group.lights) - for scene in scenes: - if group_lights == scene.lights: - scene_id = scene.id - break + # Additional scene logic to handle duplicate scene names across groups + scene = next( + (scene for scene in self.api.scenes.values() + if scene.name == scene_name + and group is not None + and sorted(scene.lights) == sorted(group.lights)), + None) # If we can't find it, fetch latest info. - if not updated and (group is None or scene_id is None): + if not updated and (group is None or scene is None): await self.api.groups.update() await self.api.scenes.update() await self.hue_activate_scene(call, updated=True) @@ -151,11 +143,11 @@ class HueBridge(object): LOGGER.warning('Unable to find group %s', group_name) return - if scene_id is None: + if scene is None: LOGGER.warning('Unable to find scene %s', scene_name) return - await group.set_action(scene=scene_id) + await group.set_action(scene=scene.id) async def get_bridge(hass, host, username=None): diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index c3e34b4d42b..ca0f3527f73 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.14.3'] +REQUIREMENTS = ['numpy==1.14.5'] _LOGGER = logging.getLogger(__name__) @@ -152,7 +152,6 @@ class OpenCVImageProcessor(ImageProcessingEntity): import cv2 # pylint: disable=import-error import numpy - # pylint: disable=no-member cv_image = cv2.imdecode( numpy.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) @@ -168,7 +167,6 @@ class OpenCVImageProcessor(ImageProcessingEntity): else: path = classifier - # pylint: disable=no-member cascade = cv2.CascadeClassifier(path) detections = cascade.detectMultiScale( diff --git a/homeassistant/components/insteon_plm/__init__.py b/homeassistant/components/insteon_plm/__init__.py index b2f7c8b6655..82fc6b02266 100644 --- a/homeassistant/components/insteon_plm/__init__.py +++ b/homeassistant/components/insteon_plm/__init__.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.10.0'] +REQUIREMENTS = ['insteonplm==0.11.3'] _LOGGER = logging.getLogger(__name__) @@ -300,7 +300,8 @@ class IPDB(object): OpenClosedRelay) from insteonplm.states.dimmable import (DimmableSwitch, - DimmableSwitch_Fan) + DimmableSwitch_Fan, + DimmableRemote) from insteonplm.states.sensor import (VariableSensor, OnOffSensor, @@ -328,6 +329,7 @@ class IPDB(object): State(DimmableSwitch_Fan, 'fan'), State(DimmableSwitch, 'light'), + State(DimmableRemote, 'binary_sensor'), State(X10DimmableSwitch, 'light'), State(X10OnOffSwitch, 'switch'), diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index 249f147847c..7f7377469fd 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -181,7 +181,6 @@ def devices_with_push(): def enabled_push_ids(): """Return a list of push enabled target push IDs.""" push_ids = list() - # pylint: disable=unused-variable for device in CONFIG_FILE[ATTR_DEVICES].values(): if device.get(ATTR_PUSH_ID) is not None: push_ids.append(device.get(ATTR_PUSH_ID)) @@ -203,7 +202,6 @@ def device_name_for_push_id(push_id): def setup(hass, config): """Set up the iOS component.""" - # pylint: disable=import-error global CONFIG_FILE global CONFIG_FILE_PATH diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 90ab41cf98b..d8afb7be5da 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -11,12 +11,12 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.core import HomeAssistant # noqa +from homeassistant.core import HomeAssistant from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType, Dict # noqa +from homeassistant.helpers.typing import ConfigType, Dict REQUIREMENTS = ['PyISY==1.1.0'] @@ -268,7 +268,6 @@ def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool: def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str, sensor_identifier: str)-> None: """Sort the nodes to their proper domains.""" - # pylint: disable=no-member for (path, node) in nodes: ignored = ignore_identifier in path or ignore_identifier in node.name if ignored: diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index af45bd3d4f9..bbd7bc44082 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -151,7 +151,6 @@ class KeyboardRemoteThread(threading.Thread): if not event: continue - # pylint: disable=no-member if event.type is ecodes.EV_KEY and event.value is self.key_value: _LOGGER.debug(categorize(event)) self.hass.bus.fire( diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py index 5b28b7b0999..26fe356d772 100644 --- a/homeassistant/components/konnected.py +++ b/homeassistant/components/konnected.py @@ -10,7 +10,7 @@ import json import voluptuous as vol from aiohttp.hdrs import AUTHORIZATION -from aiohttp.web import Request, Response # NOQA +from aiohttp.web import Request, Response from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.components.discovery import SERVICE_KONNECTED diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index 49b4f73ea17..96ea3781566 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -31,7 +31,6 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=broad-except def setup(hass, config): """Set up the LaMetricManager.""" _LOGGER.debug("Setting up LaMetric platform") diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 30a1a800a44..b8a97607215 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -446,8 +446,6 @@ class Profiles: class Light(ToggleEntity): """Representation of a light.""" - # pylint: disable=no-self-use - @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index b4b9f4e7775..be608ea4776 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Avion switch.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion lights = [] @@ -70,7 +70,7 @@ class AvionLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion self._name = device['name'] @@ -117,7 +117,7 @@ class AvionLight(Light): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion # Bluetooth LE is unreliable, and the connection may drop at any diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index 97edd7c54d2..7035320945a 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import blinkt # ensure that the lights are off when exiting diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index a4593a72617..05907ea86ee 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -101,9 +101,11 @@ class DeconzLight(Light): return self._light.ct @property - def xy_color(self): - """Return the XY color value.""" - return self._light.xy + def hs_color(self): + """Return the hs color value.""" + if self._light.colormode in ('xy', 'hs') and self._light.xy: + return color_util.color_xy_to_hs(*self._light.xy) + return None @property def is_on(self): diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index c7478b435ee..85d9180c59b 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -75,7 +75,7 @@ class DecoraLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import decora self._name = device['name'] diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 111d39f2019..17003d51610 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -36,7 +36,7 @@ NOTIFICATION_TITLE = 'myLeviton Decora Setup' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Decora WiFi platform.""" - # pylint: disable=import-error, no-member, no-name-in-module + # pylint: disable=import-error, no-name-in-module from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index fc85e05238f..b9db9d4f99b 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -33,6 +33,10 @@ SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' +# This mode enables white value to be controlled by brightness. +# RGB value is ignored when this mode is specified. +MODE_WHITE = 'w' + # List of supported effects which aren't already declared in LIGHT EFFECT_RED_FADE = 'red_fade' EFFECT_GREEN_FADE = 'green_fade' @@ -84,7 +88,7 @@ FLUX_EFFECT_LIST = [ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_RGBW): - vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB])), + vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB, MODE_WHITE])), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(['ledenet'])), }) @@ -181,6 +185,9 @@ class FluxLight(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" + if self._mode == MODE_WHITE: + return self.white_value + return self._bulb.brightness @property @@ -191,9 +198,12 @@ class FluxLight(Light): @property def supported_features(self): """Flag supported features.""" - if self._mode is MODE_RGBW: + if self._mode == MODE_RGBW: return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE + if self._mode == MODE_WHITE: + return SUPPORT_BRIGHTNESS + return SUPPORT_FLUX_LED @property @@ -208,9 +218,6 @@ class FluxLight(Light): def turn_on(self, **kwargs): """Turn the specified or all lights on.""" - if not self.is_on: - self._bulb.turnOn() - hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color: @@ -247,10 +254,23 @@ class FluxLight(Light): if rgb is None: rgb = self._bulb.getRgb() - self._bulb.setRgb(*tuple(rgb), brightness=brightness) + if white is None and self._mode == MODE_RGBW: + white = self.white_value - if white is not None: - self._bulb.setWarmWhite255(white) + # handle W only mode (use brightness instead of white value) + if self._mode == MODE_WHITE: + self._bulb.setRgbw(0, 0, 0, w=brightness) + + # handle RGBW mode + elif self._mode == MODE_RGBW: + self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) + + # handle RGB mode + else: + self._bulb.setRgb(*tuple(rgb), brightness=brightness) + + if not self.is_on: + self._bulb.turnOn() def turn_off(self, **kwargs): """Turn the specified or all lights off.""" diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py index e6dc09e455c..8d77cb05236 100644 --- a/homeassistant/components/light/homekit_controller.py +++ b/homeassistant/components/light/homekit_controller.py @@ -4,7 +4,6 @@ Support for Homekit lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.homekit_controller/ """ -import json import logging from homeassistant.components.homekit_controller import ( @@ -122,13 +121,11 @@ class HomeKitLight(HomeKitEntity, Light): characteristics.append({'aid': self._aid, 'iid': self._chars['on'], 'value': True}) - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) def turn_off(self, **kwargs): """Turn the specified light off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 421356f07bc..9b2c183c1d1 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -446,7 +446,9 @@ class LIFXLight(Light): @property def color_temp(self): """Return the color temperature.""" - kelvin = self.device.color[3] + _, sat, _, kelvin = self.device.color + if sat: + return None return color_util.color_temperature_kelvin_to_mired(kelvin) @property @@ -601,7 +603,7 @@ class LIFXColor(LIFXLight): hue, sat, _, _ = self.device.color hue = hue / 65535 * 360 sat = sat / 65535 * 100 - return (hue, sat) + return (hue, sat) if sat else None class LIFXStrip(LIFXColor): diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index bd4fece89e3..71d3f9d95d7 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -136,7 +136,7 @@ def state(new_state): """ def decorator(function): """Set up the decorator function.""" - # pylint: disable=no-member,protected-access + # pylint: disable=protected-access def wrapper(self, **kwargs): """Wrap a group state change.""" from limitlessled.pipeline import Pipeline diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 55387288d7f..4139abd40fa 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -28,7 +28,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsLight(mysensors.MySensorsEntity, Light): +class MySensorsLight(mysensors.device.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index d7544cb6c5a..09a4fa3610d 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -19,7 +19,7 @@ from homeassistant.util.color import \ from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired) -REQUIREMENTS = ['pyHS100==0.3.1'] +REQUIREMENTS = ['pyHS100==0.3.2'] _LOGGER = logging.getLogger(__name__) @@ -104,6 +104,16 @@ class TPLinkSmartBulb(Light): """Turn the light off.""" self.smartbulb.state = self.smartbulb.BULB_STATE_OFF + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return kelvin_to_mired(self.smartbulb.valid_temperature_range[1]) + + @property + def max_mireds(self): + """Return maximum supported color temperature.""" + return kelvin_to_mired(self.smartbulb.valid_temperature_range[0]) + @property def color_temp(self): """Return the color temperature of this light in mireds for HA.""" diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index fcf3d2f7a7d..4cd34b698da 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -107,6 +107,11 @@ class WemoLight(Light): """Flag supported features.""" return SUPPORT_WEMO + @property + def available(self): + """Return if light is available.""" + return self.device.state['available'] + def turn_on(self, **kwargs): """Turn the light on.""" transitiontime = int(kwargs.get(ATTR_TRANSITION, 0)) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 04216780c80..3bfa167f8ec 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/light.zwave/ """ import logging -# Because we do not compile openzwave on CI -# pylint: disable=import-error from threading import Timer from homeassistant.components.light import ( ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index 0cd49ab6c9a..d7ec49e0096 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -4,7 +4,7 @@ LIRC interface to receive signals from an infrared remote control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/lirc/ """ -# pylint: disable=import-error,no-member +# pylint: disable=no-member import threading import time import logging diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index b3e4ac8f0ff..f03d028a38f 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -145,7 +145,6 @@ class LockDevice(Entity): """Last change triggered by.""" return None - # pylint: disable=no-self-use @property def code_format(self): """Regex for code format or None if no code is required.""" diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index 79e4308dbda..9bcf5a86d08 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/lock.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.lock import LockDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, diff --git a/homeassistant/components/lock/sesame.py b/homeassistant/components/lock/sesame.py index 09f7266d15c..8d9c05e3f26 100644 --- a/homeassistant/components/lock/sesame.py +++ b/homeassistant/components/lock/sesame.py @@ -4,7 +4,7 @@ Support for Sesame, by CANDY HOUSE. For more details about this platform, please refer to the documentation https://home-assistant.io/components/lock.sesame/ """ -from typing import Callable # noqa +from typing import Callable import voluptuous as vol import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 8f39d440cae..b7bc9f15e19 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -4,8 +4,6 @@ Z-Wave platform that handles simple door locks. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/lock.zwave/ """ -# Because we do not compile openzwave on CI -# pylint: disable=import-error import asyncio import logging diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index daaffd0174c..0baca2f341c 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -55,7 +55,6 @@ def set_level(hass, logs): class HomeAssistantLogFilter(logging.Filter): """A log filter.""" - # pylint: disable=no-init def __init__(self, logfilter): """Initialize the filter.""" super().__init__() diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 75b90b084fc..85895fdd751 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.06.02'] +REQUIREMENTS = ['youtube_dl==2018.06.25'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d963deba7b5..d314dec65ea 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -471,7 +471,6 @@ class MediaPlayerDevice(Entity): _access_token = None - # pylint: disable=no-self-use # Implement these for your media player @property def state(self): diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 727bda3be3f..464baed1686 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -18,9 +18,7 @@ from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.7.zip' - '#braviarc==0.3.7'] +REQUIREMENTS = ['braviarc-homeassistant==0.3.7.dev0'] BRAVIA_CONFIG_FILE = 'bravia.conf' diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 405c220c877..9edf69cd9c6 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -295,7 +295,6 @@ class DemoMusicPlayer(AbstractDemoPlayer): @property def media_album_name(self): """Return the album of current playing media (Music track only).""" - # pylint: disable=no-self-use return "Bounzz" @property diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 8cd47476058..ff0e4d907b1 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -61,7 +61,6 @@ NewHost = namedtuple('NewHost', ['host', 'name']) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Denon platform.""" - # pylint: disable=import-error import denonavr # Initialize list with receivers to be started diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 157db2c44d3..280a84f0828 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY, - MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, STATE_UNKNOWN, CONF_HOST, CONF_PORT, CONF_SSL, CONF_NAME, CONF_DEVICE, @@ -23,7 +23,8 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET | SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET | \ + SUPPORT_PLAY DEFAULT_SSL = False DEFAULT_DEVICE = 'default' @@ -33,6 +34,7 @@ DEFAULT_PORT = 5556 DEVICE_ACTION_URL = '{0}://{1}:{2}/devices/action/{3}/{4}' DEVICE_LIST_URL = '{0}://{1}:{2}/devices/list' DEVICE_STATE_URL = '{0}://{1}:{2}/devices/state/{3}' +DEVICE_APPS_URL = '{0}://{1}:{2}/devices/{3}/apps/{4}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, @@ -98,6 +100,38 @@ class FireTV(object): "Could not retrieve device state for %s", self.device_id) return STATE_UNKNOWN + @property + def current_app(self): + """Return the current app.""" + try: + response = requests.get( + DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, 'current' + ), timeout=10).json() + _current_app = response.get('current_app') + if _current_app: + return _current_app.get('package') + + return None + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not retrieve current app for %s", self.device_id) + return None + + @property + def running_apps(self): + """Return a list of running apps.""" + try: + response = requests.get( + DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, 'running' + ), timeout=10).json() + return response.get('running_apps') + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not retrieve running apps for %s", self.device_id) + return None + def action(self, action_id): """Perform an action on the device.""" try: @@ -109,6 +143,16 @@ class FireTV(object): "Action request for %s was not accepted for device %s", action_id, self.device_id) + def start_app(self, app_name): + """Start an app.""" + try: + requests.get(DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, + app_name + '/start'), timeout=10) + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not start %s on %s", app_name, self.device_id) + class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" @@ -118,6 +162,8 @@ class FireTVDevice(MediaPlayerDevice): self._firetv = FireTV(proto, host, port, device) self._name = name self._state = STATE_UNKNOWN + self._running_apps = None + self._current_app = None @property def name(self): @@ -139,6 +185,16 @@ class FireTVDevice(MediaPlayerDevice): """Return the state of the player.""" return self._state + @property + def source(self): + """Return the current app.""" + return self._current_app + + @property + def source_list(self): + """Return a list of running apps.""" + return self._running_apps + def update(self): """Get the latest date and update device state.""" self._state = { @@ -150,6 +206,13 @@ class FireTVDevice(MediaPlayerDevice): 'disconnected': STATE_UNKNOWN, }.get(self._firetv.state, STATE_UNKNOWN) + if self._state not in [STATE_OFF, STATE_UNKNOWN]: + self._running_apps = self._firetv.running_apps + self._current_app = self._firetv.current_app + else: + self._running_apps = None + self._current_app = None + def turn_on(self): """Turn on the device.""" self._firetv.action('turn_on') @@ -185,3 +248,7 @@ class FireTVDevice(MediaPlayerDevice): def media_next_track(self): """Send next track command (results in fast-forward).""" self._firetv.action('media_next') + + def select_source(self, source): + """Select input source.""" + self._firetv.start_app(source) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 6690382846f..ca6b9722a49 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -747,7 +747,6 @@ class PlexClient(MediaPlayerDevice): if self.device and 'playback' in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - # pylint: disable=W0613 def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" if not (self.device and diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 15a2b41795e..c3de341d607 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -4,6 +4,7 @@ Support for interface with an Samsung TV. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.samsungtv/ """ +import asyncio import logging import socket from datetime import timedelta @@ -15,8 +16,9 @@ import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_TURN_ON) + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_PLAY, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY_MEDIA, + MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_CHANNEL) from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT, CONF_MAC) @@ -32,12 +34,13 @@ CONF_TIMEOUT = 'timeout' DEFAULT_NAME = 'Samsung TV Remote' DEFAULT_PORT = 55000 DEFAULT_TIMEOUT = 0 +KEY_PRESS_TIMEOUT = 1.2 KNOWN_DEVICES_KEY = 'samsungtv_known_devices' SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -256,6 +259,23 @@ class SamsungTVDevice(MediaPlayerDevice): """Send the previous track command.""" self.send_key('KEY_REWIND') + async def async_play_media(self, media_type, media_id, **kwargs): + """Support changing a channel.""" + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error('Unsupported media type') + return + + # media_id should only be a channel number + try: + cv.positive_int(media_id) + except vol.Invalid: + _LOGGER.error('Media ID must be positive integer') + return + + for digit in media_id: + await self.hass.async_add_job(self.send_key, 'KEY_' + digit) + await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) + def turn_on(self): """Turn the media player on.""" if self._mac: diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 03f847ae40c..66d12190320 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.universal/ """ import logging -# pylint: disable=import-error from copy import copy import voluptuous as vol diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 381482a4839..81e4c3541d3 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + MediaPlayerDevice) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) @@ -39,7 +40,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) SUPPORTED_COMMANDS = SUPPORT_TURN_ON | SUPPORT_TURN_OFF \ | SUPPORT_SELECT_SOURCE \ | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \ - | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP \ + | SUPPORT_VOLUME_SET PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -98,7 +100,9 @@ class VizioDevice(MediaPlayerDevice): else: self._state = STATE_ON - self._volume_level = self._device.get_current_volume() + volume = self._device.get_current_volume() + if volume is not None: + self._volume_level = float(volume) / 100. input_ = self._device.get_current_input() if input_ is not None: self._current_input = input_.meta_name @@ -167,12 +171,26 @@ class VizioDevice(MediaPlayerDevice): def volume_up(self): """Increasing volume of the TV.""" + self._volume_level += self._volume_step / 100. self._device.vol_up(num=self._volume_step) def volume_down(self): """Decreasing volume of the TV.""" + self._volume_level -= self._volume_step / 100. self._device.vol_down(num=self._volume_step) def validate_setup(self): """Validate if host is available and key is correct.""" return self._device.get_current_volume() is not None + + def set_volume_level(self, volume): + """Set volume level.""" + if self._volume_level is not None: + if volume > self._volume_level: + num = int(100*(volume - self._volume_level)) + self._volume_level = volume + self._device.vol_up(num=num) + elif volume < self._volume_level: + num = int(100*(self._volume_level - volume)) + self._volume_level = volume + self._device.vol_down(num=num) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index fe46c858b51..fc6db96e029 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -75,7 +75,6 @@ HUB = None def setup(hass, config): """Set up Modbus component.""" # Modbus connection type - # pylint: disable=import-error client_type = config[DOMAIN][CONF_TYPE] # Connect to Modbus network diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d5a3b4a2efb..3916714b8d1 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -21,7 +21,7 @@ TOPIC_MATCHER = re.compile( SUPPORTED_COMPONENTS = [ 'binary_sensor', 'camera', 'cover', 'fan', - 'light', 'sensor', 'switch', 'lock'] + 'light', 'sensor', 'switch', 'lock', 'climate'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], @@ -32,6 +32,7 @@ ALLOWED_PLATFORMS = { 'lock': ['mqtt'], 'sensor': ['mqtt'], 'switch': ['mqtt'], + 'climate': ['mqtt'], } ALREADY_DISCOVERED = 'mqtt_discovered_components' diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py deleted file mode 100644 index 1e7e252bd9d..00000000000 --- a/homeassistant/components/mysensors.py +++ /dev/null @@ -1,705 +0,0 @@ -""" -Connect to a MySensors gateway via pymysensors API. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mysensors/ -""" -import asyncio -from collections import defaultdict -import logging -import os -import socket -import sys -from timeit import default_timer as timer - -import async_timeout -import voluptuous as vol - -from homeassistant.components.mqtt import ( - valid_publish_topic, valid_subscribe_topic) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP, - STATE_OFF, STATE_ON) -from homeassistant.core import callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) -from homeassistant.helpers.entity import Entity -from homeassistant.setup import async_setup_component - -REQUIREMENTS = ['pymysensors==0.14.0'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_CHILD_ID = 'child_id' -ATTR_DESCRIPTION = 'description' -ATTR_DEVICE = 'device' -ATTR_DEVICES = 'devices' -ATTR_NODE_ID = 'node_id' - -CONF_BAUD_RATE = 'baud_rate' -CONF_DEBUG = 'debug' -CONF_DEVICE = 'device' -CONF_GATEWAYS = 'gateways' -CONF_PERSISTENCE = 'persistence' -CONF_PERSISTENCE_FILE = 'persistence_file' -CONF_RETAIN = 'retain' -CONF_TCP_PORT = 'tcp_port' -CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' -CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' -CONF_VERSION = 'version' - -CONF_NODES = 'nodes' -CONF_NODE_NAME = 'name' - -DEFAULT_BAUD_RATE = 115200 -DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = '1.4' -DOMAIN = 'mysensors' - -GATEWAY_READY_TIMEOUT = 15.0 -MQTT_COMPONENT = 'mqtt' -MYSENSORS_GATEWAYS = 'mysensors_gateways' -MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' -MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' -PLATFORM = 'platform' -SCHEMA = 'schema' -SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' -TYPE = 'type' - - -def is_socket_address(value): - """Validate that value is a valid address.""" - try: - socket.getaddrinfo(value, None) - return value - except OSError: - raise vol.Invalid('Device is not a valid domain name or ip address') - - -def has_parent_dir(value): - """Validate that value is in an existing directory which is writeable.""" - parent = os.path.dirname(os.path.realpath(value)) - is_dir_writable = os.path.isdir(parent) and os.access(parent, os.W_OK) - if not is_dir_writable: - raise vol.Invalid( - '{} directory does not exist or is not writeable'.format(parent)) - return value - - -def has_all_unique_files(value): - """Validate that all persistence files are unique and set if any is set.""" - persistence_files = [ - gateway.get(CONF_PERSISTENCE_FILE) for gateway in value] - if None in persistence_files and any( - name is not None for name in persistence_files): - raise vol.Invalid( - 'persistence file name of all devices must be set if any is set') - if not all(name is None for name in persistence_files): - schema = vol.Schema(vol.Unique()) - schema(persistence_files) - return value - - -def is_persistence_file(value): - """Validate that persistence file path ends in either .pickle or .json.""" - if value.endswith(('.json', '.pickle')): - return value - else: - raise vol.Invalid( - '{} does not end in either `.json` or `.pickle`'.format(value)) - - -def is_serial_port(value): - """Validate that value is a windows serial port or a unix device.""" - if sys.platform.startswith('win'): - ports = ('COM{}'.format(idx + 1) for idx in range(256)) - if value in ports: - return value - else: - raise vol.Invalid('{} is not a serial port'.format(value)) - else: - return cv.isdevice(value) - - -def deprecated(key): - """Mark key as deprecated in configuration.""" - def validator(config): - """Check if key is in config, log warning and remove key.""" - if key not in config: - return config - _LOGGER.warning( - '%s option for %s is deprecated. Please remove %s from your ' - 'configuration file', key, DOMAIN, key) - config.pop(key) - return config - return validator - - -NODE_SCHEMA = vol.Schema({ - cv.positive_int: { - vol.Required(CONF_NODE_NAME): cv.string - } -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { - vol.Required(CONF_GATEWAYS): vol.All( - cv.ensure_list, has_all_unique_files, - [{ - vol.Required(CONF_DEVICE): - vol.Any(MQTT_COMPONENT, is_socket_address, is_serial_port), - vol.Optional(CONF_PERSISTENCE_FILE): - vol.All(cv.string, is_persistence_file, has_parent_dir), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): - cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, - }] - ), - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, - vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - })) -}, extra=vol.ALLOW_EXTRA) - - -# MySensors const schemas -BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} -CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} -LIGHT_DIMMER_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_DIMMER', - SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} -LIGHT_PERCENTAGE_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_PERCENTAGE', - SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGB_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { - 'V_RGB': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGBW_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { - 'V_RGBW': cv.string, 'V_STATUS': cv.string}} -NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} -DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} -DUST_SCHEMA = [ - {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] -SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} -SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} -MYSENSORS_CONST_SCHEMA = { - 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SPRINKLER': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_WATER_LEAK': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SOUND': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_VIBRATION': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOISTURE': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_HVAC': [CLIMATE_SCHEMA], - 'S_COVER': [ - {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, - {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, - {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, - {PLATFORM: 'cover', TYPE: 'V_STATUS'}], - 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], - 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], - 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], - 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], - 'S_GPS': [ - DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], - 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], - 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], - 'S_BARO': [ - {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, - {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], - 'S_WIND': [ - {PLATFORM: 'sensor', TYPE: 'V_WIND'}, - {PLATFORM: 'sensor', TYPE: 'V_GUST'}, - {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], - 'S_RAIN': [ - {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, - {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], - 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], - 'S_WEIGHT': [ - {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_POWER': [ - {PLATFORM: 'sensor', TYPE: 'V_WATT'}, - {PLATFORM: 'sensor', TYPE: 'V_KWH'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR'}, - {PLATFORM: 'sensor', TYPE: 'V_VA'}, - {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], - 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], - 'S_LIGHT_LEVEL': [ - {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], - 'S_IR': [ - {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, - {PLATFORM: 'switch', TYPE: 'V_IR_SEND', - SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], - 'S_WATER': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_CUSTOM': [ - {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, - {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], - 'S_SCENE_CONTROLLER': [ - {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, - {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], - 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], - 'S_MULTIMETER': [ - {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, - {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_GAS': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_WATER_QUALITY': [ - {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, - {PLATFORM: 'sensor', TYPE: 'V_PH'}, - {PLATFORM: 'sensor', TYPE: 'V_ORP'}, - {PLATFORM: 'sensor', TYPE: 'V_EC'}, - {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_AIR_QUALITY': DUST_SCHEMA, - 'S_DUST': DUST_SCHEMA, - 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], - 'S_BINARY': [SWITCH_STATUS_SCHEMA], - 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], -} - - -async def async_setup(hass, config): - """Set up the MySensors component.""" - import mysensors.mysensors as mysensors - - version = config[DOMAIN].get(CONF_VERSION) - persistence = config[DOMAIN].get(CONF_PERSISTENCE) - - async def setup_gateway( - device, persistence_file, baud_rate, tcp_port, in_prefix, - out_prefix): - """Return gateway after setup of the gateway.""" - if device == MQTT_COMPONENT: - if not await async_setup_component(hass, MQTT_COMPONENT, config): - return None - mqtt = hass.components.mqtt - retain = config[DOMAIN].get(CONF_RETAIN) - - def pub_callback(topic, payload, qos, retain): - """Call MQTT publish function.""" - mqtt.async_publish(topic, payload, qos, retain) - - def sub_callback(topic, sub_cb, qos): - """Call MQTT subscribe function.""" - @callback - def internal_callback(*args): - """Call callback.""" - sub_cb(*args) - - hass.async_add_job( - mqtt.async_subscribe(topic, internal_callback, qos)) - - gateway = mysensors.AsyncMQTTGateway( - pub_callback, sub_callback, in_prefix=in_prefix, - out_prefix=out_prefix, retain=retain, loop=hass.loop, - event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version) - else: - try: - await hass.async_add_job(is_serial_port, device) - gateway = mysensors.AsyncSerialGateway( - device, baud=baud_rate, loop=hass.loop, - event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version) - except vol.Invalid: - gateway = mysensors.AsyncTCPGateway( - device, port=tcp_port, loop=hass.loop, event_callback=None, - persistence=persistence, persistence_file=persistence_file, - protocol_version=version) - gateway.metric = hass.config.units.is_metric - gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) - gateway.device = device - gateway.event_callback = gw_callback_factory(hass) - if persistence: - await gateway.start_persistence() - - return gateway - - # Setup all devices from config - gateways = {} - conf_gateways = config[DOMAIN][CONF_GATEWAYS] - - for index, gway in enumerate(conf_gateways): - device = gway[CONF_DEVICE] - persistence_file = gway.get( - CONF_PERSISTENCE_FILE, - hass.config.path('mysensors{}.pickle'.format(index + 1))) - baud_rate = gway.get(CONF_BAUD_RATE) - tcp_port = gway.get(CONF_TCP_PORT) - in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '') - out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '') - gateway = await setup_gateway( - device, persistence_file, baud_rate, tcp_port, in_prefix, - out_prefix) - if gateway is not None: - gateway.nodes_config = gway.get(CONF_NODES) - gateways[id(gateway)] = gateway - - if not gateways: - _LOGGER.error( - "No devices could be setup as gateways, check your configuration") - return False - - hass.data[MYSENSORS_GATEWAYS] = gateways - - hass.async_add_job(finish_setup(hass, gateways)) - - return True - - -async def finish_setup(hass, gateways): - """Load any persistent devices and platforms and start gateway.""" - discover_tasks = [] - start_tasks = [] - for gateway in gateways.values(): - discover_tasks.append(discover_persistent_devices(hass, gateway)) - start_tasks.append(gw_start(hass, gateway)) - if discover_tasks: - # Make sure all devices and platforms are loaded before gateway start. - await asyncio.wait(discover_tasks, loop=hass.loop) - if start_tasks: - await asyncio.wait(start_tasks, loop=hass.loop) - - -async def gw_start(hass, gateway): - """Start the gateway.""" - @callback - def gw_stop(event): - """Trigger to stop the gateway.""" - hass.async_add_job(gateway.stop()) - - await gateway.start() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) - if gateway.device == 'mqtt': - # Gatways connected via mqtt doesn't send gateway ready message. - return - gateway_ready = asyncio.Future() - gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) - hass.data[gateway_ready_key] = gateway_ready - - try: - with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop): - await gateway_ready - except asyncio.TimeoutError: - _LOGGER.warning( - "Gateway %s not ready after %s secs so continuing with setup", - gateway.device, GATEWAY_READY_TIMEOUT) - finally: - hass.data.pop(gateway_ready_key, None) - - -@callback -def set_gateway_ready(hass, msg): - """Set asyncio future result if gateway is ready.""" - if (msg.type != msg.gateway.const.MessageType.internal or - msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): - return - gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( - id(msg.gateway))) - if gateway_ready is None or gateway_ready.cancelled(): - return - gateway_ready.set_result(True) - - -def validate_child(gateway, node_id, child): - """Validate that a child has the correct values according to schema. - - Return a dict of platform with a list of device ids for validated devices. - """ - validated = defaultdict(list) - - if not child.values: - _LOGGER.debug( - "No child values for node %s child %s", node_id, child.id) - return validated - if gateway.sensors[node_id].sketch_name is None: - _LOGGER.debug("Node %s is missing sketch name", node_id) - return validated - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - s_name = next( - (member.name for member in pres if member.value == child.type), None) - if s_name not in MYSENSORS_CONST_SCHEMA: - _LOGGER.warning("Child type %s is not supported", s_name) - return validated - child_schemas = MYSENSORS_CONST_SCHEMA[s_name] - - def msg(name): - """Return a message for an invalid schema.""" - return "{} requires value_type {}".format( - pres(child.type).name, set_req[name].name) - - for schema in child_schemas: - platform = schema[PLATFORM] - v_name = schema[TYPE] - value_type = next( - (member.value for member in set_req if member.name == v_name), - None) - if value_type is None: - continue - _child_schema = child.get_schema(gateway.protocol_version) - vol_schema = _child_schema.extend( - {vol.Required(set_req[key].value, msg=msg(key)): - _child_schema.schema.get(set_req[key].value, val) - for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, - extra=vol.ALLOW_EXTRA) - try: - vol_schema(child.values) - except vol.Invalid as exc: - level = (logging.WARNING if value_type in child.values - else logging.DEBUG) - _LOGGER.log( - level, - "Invalid values: %s: %s platform: node %s child %s: %s", - child.values, platform, node_id, child.id, exc) - continue - dev_id = id(gateway), node_id, child.id, value_type - validated[platform].append(dev_id) - return validated - - -@callback -def discover_mysensors_platform(hass, platform, new_devices): - """Discover a MySensors platform.""" - task = hass.async_add_job(discovery.async_load_platform( - hass, platform, DOMAIN, - {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN})) - return task - - -async def discover_persistent_devices(hass, gateway): - """Discover platforms for devices loaded via persistence file.""" - tasks = [] - new_devices = defaultdict(list) - for node_id in gateway.sensors: - node = gateway.sensors[node_id] - for child in node.children.values(): - validated = validate_child(gateway, node_id, child) - for platform, dev_ids in validated.items(): - new_devices[platform].extend(dev_ids) - for platform, dev_ids in new_devices.items(): - tasks.append(discover_mysensors_platform(hass, platform, dev_ids)) - if tasks: - await asyncio.wait(tasks, loop=hass.loop) - - -def get_mysensors_devices(hass, domain): - """Return MySensors devices for a platform.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: - hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] - - -def gw_callback_factory(hass): - """Return a new callback for the gateway.""" - @callback - def mysensors_callback(msg): - """Handle messages from a MySensors gateway.""" - start = timer() - _LOGGER.debug( - "Node update: node %s child %s", msg.node_id, msg.child_id) - - set_gateway_ready(hass, msg) - - try: - child = msg.gateway.sensors[msg.node_id].children[msg.child_id] - except KeyError: - _LOGGER.debug("Not a child update for node %s", msg.node_id) - return - - signals = [] - - # Update all platforms for the device via dispatcher. - # Add/update entity if schema validates to true. - validated = validate_child(msg.gateway, msg.node_id, child) - for platform, dev_ids in validated.items(): - devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] - for dev_id in dev_ids: - if dev_id in devices: - signals.append(SIGNAL_CALLBACK.format(*dev_id)) - else: - new_dev_ids.append(dev_id) - if new_dev_ids: - discover_mysensors_platform(hass, platform, new_dev_ids) - for signal in set(signals): - # Only one signal per device is needed. - # A device can have multiple platforms, ie multiple schemas. - # FOR LATER: Add timer to not signal if another update comes in. - async_dispatcher_send(hass, signal) - end = timer() - if end - start > 0.1: - _LOGGER.debug( - "Callback for node %s child %s took %.3f seconds", - msg.node_id, msg.child_id, end - start) - return mysensors_callback - - -def get_mysensors_name(gateway, node_id, child_id): - """Return a name for a node child.""" - node_name = '{} {}'.format( - gateway.sensors[node_id].sketch_name, node_id) - node_name = next( - (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() - if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), - node_name) - return '{} {}'.format(node_name, child_id) - - -def get_mysensors_gateway(hass, gateway_id): - """Return MySensors gateway.""" - if MYSENSORS_GATEWAYS not in hass.data: - hass.data[MYSENSORS_GATEWAYS] = {} - gateways = hass.data.get(MYSENSORS_GATEWAYS) - return gateways.get(gateway_id) - - -@callback -def setup_mysensors_platform( - hass, domain, discovery_info, device_class, device_args=None, - async_add_devices=None): - """Set up a MySensors platform.""" - # Only act if called via mysensors by discovery event. - # Otherwise gateway is not setup. - if not discovery_info: - return - if device_args is None: - device_args = () - new_devices = [] - new_dev_ids = discovery_info[ATTR_DEVICES] - for dev_id in new_dev_ids: - devices = get_mysensors_devices(hass, domain) - if dev_id in devices: - continue - gateway_id, node_id, child_id, value_type = dev_id - gateway = get_mysensors_gateway(hass, gateway_id) - if not gateway: - continue - device_class_copy = device_class - if isinstance(device_class, dict): - child = gateway.sensors[node_id].children[child_id] - s_type = gateway.const.Presentation(child.type).name - device_class_copy = device_class[s_type] - name = get_mysensors_name(gateway, node_id, child_id) - - args_copy = (*device_args, gateway, node_id, child_id, name, - value_type) - devices[dev_id] = device_class_copy(*args_copy) - new_devices.append(devices[dev_id]) - if new_devices: - _LOGGER.info("Adding new devices: %s", new_devices) - if async_add_devices is not None: - async_add_devices(new_devices, True) - return new_devices - - -class MySensorsDevice(object): - """Representation of a MySensors device.""" - - def __init__(self, gateway, node_id, child_id, name, value_type): - """Set up the MySensors device.""" - self.gateway = gateway - self.node_id = node_id - self.child_id = child_id - self._name = name - self.value_type = value_type - child = gateway.sensors[node_id].children[child_id] - self.child_type = child.type - self._values = {} - - @property - def name(self): - """Return the name of this entity.""" - return self._name - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - attr = { - ATTR_BATTERY_LEVEL: node.battery_level, - ATTR_CHILD_ID: self.child_id, - ATTR_DESCRIPTION: child.description, - ATTR_DEVICE: self.gateway.device, - ATTR_NODE_ID: self.node_id, - } - - set_req = self.gateway.const.SetReq - - for value_type, value in self._values.items(): - attr[set_req(value_type).name] = value - - return attr - - async def async_update(self): - """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - set_req = self.gateway.const.SetReq - for value_type, value in child.values.items(): - _LOGGER.debug( - "Entity update: %s: value_type %s, value = %s", - self._name, value_type, value) - if value_type in (set_req.V_ARMED, set_req.V_LIGHT, - set_req.V_LOCK_STATUS, set_req.V_TRIPPED): - self._values[value_type] = ( - STATE_ON if int(value) == 1 else STATE_OFF) - elif value_type == set_req.V_DIMMER: - self._values[value_type] = int(value) - else: - self._values[value_type] = value - - -class MySensorsEntity(MySensorsDevice, Entity): - """Representation of a MySensors entity.""" - - @property - def should_poll(self): - """Return the polling state. The gateway pushes its states.""" - return False - - @property - def available(self): - """Return true if entity is available.""" - return self.value_type in self._values - - @callback - def async_update_callback(self): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async def async_added_to_hass(self): - """Register update callback.""" - dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type - async_dispatcher_connect( - self.hass, SIGNAL_CALLBACK.format(*dev_id), - self.async_update_callback) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py new file mode 100644 index 00000000000..3aa8e82911e --- /dev/null +++ b/homeassistant/components/mysensors/__init__.py @@ -0,0 +1,167 @@ +""" +Connect to a MySensors gateway via pymysensors API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mysensors/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.mqtt import ( + valid_publish_topic, valid_subscribe_topic) +from homeassistant.const import CONF_OPTIMISTIC +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, + CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, + CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, + DOMAIN, MYSENSORS_GATEWAYS) +from .device import get_mysensors_devices +from .gateway import get_mysensors_gateway, setup_gateways, finish_setup + +REQUIREMENTS = ['pymysensors==0.14.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEBUG = 'debug' +CONF_NODE_NAME = 'name' + +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DEFAULT_VERSION = '1.4' + + +def has_all_unique_files(value): + """Validate that all persistence files are unique and set if any is set.""" + persistence_files = [ + gateway.get(CONF_PERSISTENCE_FILE) for gateway in value] + if None in persistence_files and any( + name is not None for name in persistence_files): + raise vol.Invalid( + 'persistence file name of all devices must be set if any is set') + if not all(name is None for name in persistence_files): + schema = vol.Schema(vol.Unique()) + schema(persistence_files) + return value + + +def is_persistence_file(value): + """Validate that persistence file path ends in either .pickle or .json.""" + if value.endswith(('.json', '.pickle')): + return value + else: + raise vol.Invalid( + '{} does not end in either `.json` or `.pickle`'.format(value)) + + +def deprecated(key): + """Mark key as deprecated in configuration.""" + def validator(config): + """Check if key is in config, log warning and remove key.""" + if key not in config: + return config + _LOGGER.warning( + '%s option for %s is deprecated. Please remove %s from your ' + 'configuration file', key, DOMAIN, key) + config.pop(key) + return config + return validator + + +NODE_SCHEMA = vol.Schema({ + cv.positive_int: { + vol.Required(CONF_NODE_NAME): cv.string + } +}) + +GATEWAY_SCHEMA = { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_PERSISTENCE_FILE): + vol.All(cv.string, is_persistence_file), + vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): + cv.positive_int, + vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { + vol.Required(CONF_GATEWAYS): vol.All( + cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA]), + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, + vol.Optional(CONF_RETAIN, default=True): cv.boolean, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + })) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the MySensors component.""" + gateways = await setup_gateways(hass, config) + + if not gateways: + _LOGGER.error( + "No devices could be setup as gateways, check your configuration") + return False + + hass.data[MYSENSORS_GATEWAYS] = gateways + + hass.async_add_job(finish_setup(hass, gateways)) + + return True + + +def _get_mysensors_name(gateway, node_id, child_id): + """Return a name for a node child.""" + node_name = '{} {}'.format( + gateway.sensors[node_id].sketch_name, node_id) + node_name = next( + (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() + if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), + node_name) + return '{} {}'.format(node_name, child_id) + + +@callback +def setup_mysensors_platform( + hass, domain, discovery_info, device_class, device_args=None, + async_add_devices=None): + """Set up a MySensors platform.""" + # Only act if called via MySensors by discovery event. + # Otherwise gateway is not setup. + if not discovery_info: + return + if device_args is None: + device_args = () + new_devices = [] + new_dev_ids = discovery_info[ATTR_DEVICES] + for dev_id in new_dev_ids: + devices = get_mysensors_devices(hass, domain) + if dev_id in devices: + continue + gateway_id, node_id, child_id, value_type = dev_id + gateway = get_mysensors_gateway(hass, gateway_id) + if not gateway: + continue + device_class_copy = device_class + if isinstance(device_class, dict): + child = gateway.sensors[node_id].children[child_id] + s_type = gateway.const.Presentation(child.type).name + device_class_copy = device_class[s_type] + name = _get_mysensors_name(gateway, node_id, child_id) + + args_copy = (*device_args, gateway, node_id, child_id, name, + value_type) + devices[dev_id] = device_class_copy(*args_copy) + new_devices.append(devices[dev_id]) + if new_devices: + _LOGGER.info("Adding new devices: %s", new_devices) + if async_add_devices is not None: + async_add_devices(new_devices, True) + return new_devices diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py new file mode 100644 index 00000000000..4f9718a39db --- /dev/null +++ b/homeassistant/components/mysensors/const.py @@ -0,0 +1,138 @@ +"""MySensors constants.""" +import homeassistant.helpers.config_validation as cv + +ATTR_DEVICES = 'devices' + +CONF_BAUD_RATE = 'baud_rate' +CONF_DEVICE = 'device' +CONF_GATEWAYS = 'gateways' +CONF_NODES = 'nodes' +CONF_PERSISTENCE = 'persistence' +CONF_PERSISTENCE_FILE = 'persistence_file' +CONF_RETAIN = 'retain' +CONF_TCP_PORT = 'tcp_port' +CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' +CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' +CONF_VERSION = 'version' + +DOMAIN = 'mysensors' +MYSENSORS_GATEWAYS = 'mysensors_gateways' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' + +# MySensors const schemas +BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} +CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} +LIGHT_DIMMER_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_DIMMER', + SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} +LIGHT_PERCENTAGE_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_PERCENTAGE', + SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGB_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { + 'V_RGB': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGBW_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { + 'V_RGBW': cv.string, 'V_STATUS': cv.string}} +NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} +DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} +DUST_SCHEMA = [ + {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] +SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} +SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} +MYSENSORS_CONST_SCHEMA = { + 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SPRINKLER': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_WATER_LEAK': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SOUND': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_VIBRATION': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOISTURE': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_HVAC': [CLIMATE_SCHEMA], + 'S_COVER': [ + {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, + {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, + {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, + {PLATFORM: 'cover', TYPE: 'V_STATUS'}], + 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], + 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], + 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], + 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], + 'S_GPS': [ + DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], + 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], + 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], + 'S_BARO': [ + {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, + {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], + 'S_WIND': [ + {PLATFORM: 'sensor', TYPE: 'V_WIND'}, + {PLATFORM: 'sensor', TYPE: 'V_GUST'}, + {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], + 'S_RAIN': [ + {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, + {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], + 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], + 'S_WEIGHT': [ + {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_POWER': [ + {PLATFORM: 'sensor', TYPE: 'V_WATT'}, + {PLATFORM: 'sensor', TYPE: 'V_KWH'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR'}, + {PLATFORM: 'sensor', TYPE: 'V_VA'}, + {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], + 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], + 'S_LIGHT_LEVEL': [ + {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], + 'S_IR': [ + {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, + {PLATFORM: 'switch', TYPE: 'V_IR_SEND', + SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], + 'S_WATER': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_CUSTOM': [ + {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, + {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], + 'S_SCENE_CONTROLLER': [ + {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, + {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], + 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], + 'S_MULTIMETER': [ + {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, + {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_GAS': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_WATER_QUALITY': [ + {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, + {PLATFORM: 'sensor', TYPE: 'V_PH'}, + {PLATFORM: 'sensor', TYPE: 'V_ORP'}, + {PLATFORM: 'sensor', TYPE: 'V_EC'}, + {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_AIR_QUALITY': DUST_SCHEMA, + 'S_DUST': DUST_SCHEMA, + 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], + 'S_BINARY': [SWITCH_STATUS_SCHEMA], + 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +} diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py new file mode 100644 index 00000000000..b0770f90c1d --- /dev/null +++ b/homeassistant/components/mysensors/device.py @@ -0,0 +1,109 @@ +"""Handle MySensors devices.""" +import logging + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_CALLBACK + +_LOGGER = logging.getLogger(__name__) + +ATTR_CHILD_ID = 'child_id' +ATTR_DESCRIPTION = 'description' +ATTR_DEVICE = 'device' +ATTR_NODE_ID = 'node_id' +MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' + + +def get_mysensors_devices(hass, domain): + """Return MySensors devices for a platform.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: + hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] + + +class MySensorsDevice(object): + """Representation of a MySensors device.""" + + def __init__(self, gateway, node_id, child_id, name, value_type): + """Set up the MySensors device.""" + self.gateway = gateway + self.node_id = node_id + self.child_id = child_id + self._name = name + self.value_type = value_type + child = gateway.sensors[node_id].children[child_id] + self.child_type = child.type + self._values = {} + + @property + def name(self): + """Return the name of this entity.""" + return self._name + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + attr = { + ATTR_BATTERY_LEVEL: node.battery_level, + ATTR_CHILD_ID: self.child_id, + ATTR_DESCRIPTION: child.description, + ATTR_DEVICE: self.gateway.device, + ATTR_NODE_ID: self.node_id, + } + + set_req = self.gateway.const.SetReq + + for value_type, value in self._values.items(): + attr[set_req(value_type).name] = value + + return attr + + async def async_update(self): + """Update the controller with the latest value from a sensor.""" + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + set_req = self.gateway.const.SetReq + for value_type, value in child.values.items(): + _LOGGER.debug( + "Entity update: %s: value_type %s, value = %s", + self._name, value_type, value) + if value_type in (set_req.V_ARMED, set_req.V_LIGHT, + set_req.V_LOCK_STATUS, set_req.V_TRIPPED): + self._values[value_type] = ( + STATE_ON if int(value) == 1 else STATE_OFF) + elif value_type == set_req.V_DIMMER: + self._values[value_type] = int(value) + else: + self._values[value_type] = value + + +class MySensorsEntity(MySensorsDevice, Entity): + """Representation of a MySensors entity.""" + + @property + def should_poll(self): + """Return the polling state. The gateway pushes its states.""" + return False + + @property + def available(self): + """Return true if entity is available.""" + return self.value_type in self._values + + @callback + def async_update_callback(self): + """Update the entity.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register update callback.""" + dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + async_dispatcher_connect( + self.hass, SIGNAL_CALLBACK.format(*dev_id), + self.async_update_callback) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py new file mode 100644 index 00000000000..a7719a80d99 --- /dev/null +++ b/homeassistant/components/mysensors/gateway.py @@ -0,0 +1,328 @@ +"""Handle MySensors gateways.""" +import asyncio +from collections import defaultdict +import logging +import socket +import sys +from timeit import default_timer as timer + +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, + CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, + MYSENSORS_CONST_SCHEMA, MYSENSORS_GATEWAYS, PLATFORM, SCHEMA, + SIGNAL_CALLBACK, TYPE) +from .device import get_mysensors_devices + +_LOGGER = logging.getLogger(__name__) + +GATEWAY_READY_TIMEOUT = 15.0 +MQTT_COMPONENT = 'mqtt' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' + + +def is_serial_port(value): + """Validate that value is a windows serial port or a unix device.""" + if sys.platform.startswith('win'): + ports = ('COM{}'.format(idx + 1) for idx in range(256)) + if value in ports: + return value + else: + raise vol.Invalid('{} is not a serial port'.format(value)) + else: + return cv.isdevice(value) + + +def is_socket_address(value): + """Validate that value is a valid address.""" + try: + socket.getaddrinfo(value, None) + return value + except OSError: + raise vol.Invalid('Device is not a valid domain name or ip address') + + +def get_mysensors_gateway(hass, gateway_id): + """Return MySensors gateway.""" + if MYSENSORS_GATEWAYS not in hass.data: + hass.data[MYSENSORS_GATEWAYS] = {} + gateways = hass.data.get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) + + +async def setup_gateways(hass, config): + """Set up all gateways.""" + conf = config[DOMAIN] + gateways = {} + + for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): + persistence_file = gateway_conf.get( + CONF_PERSISTENCE_FILE, + hass.config.path('mysensors{}.pickle'.format(index + 1))) + ready_gateway = await _get_gateway( + hass, config, gateway_conf, persistence_file) + if ready_gateway is not None: + gateways[id(ready_gateway)] = ready_gateway + + return gateways + + +async def _get_gateway(hass, config, gateway_conf, persistence_file): + """Return gateway after setup of the gateway.""" + import mysensors.mysensors as mysensors + + conf = config[DOMAIN] + persistence = conf[CONF_PERSISTENCE] + version = conf[CONF_VERSION] + device = gateway_conf[CONF_DEVICE] + baud_rate = gateway_conf[CONF_BAUD_RATE] + tcp_port = gateway_conf[CONF_TCP_PORT] + in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, '') + out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, '') + + if device == MQTT_COMPONENT: + if not await async_setup_component(hass, MQTT_COMPONENT, config): + return None + mqtt = hass.components.mqtt + retain = conf[CONF_RETAIN] + + def pub_callback(topic, payload, qos, retain): + """Call MQTT publish function.""" + mqtt.async_publish(topic, payload, qos, retain) + + def sub_callback(topic, sub_cb, qos): + """Call MQTT subscribe function.""" + @callback + def internal_callback(*args): + """Call callback.""" + sub_cb(*args) + + hass.async_add_job( + mqtt.async_subscribe(topic, internal_callback, qos)) + + gateway = mysensors.AsyncMQTTGateway( + pub_callback, sub_callback, in_prefix=in_prefix, + out_prefix=out_prefix, retain=retain, loop=hass.loop, + event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version) + else: + try: + await hass.async_add_job(is_serial_port, device) + gateway = mysensors.AsyncSerialGateway( + device, baud=baud_rate, loop=hass.loop, + event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version) + except vol.Invalid: + try: + await hass.async_add_job(is_socket_address, device) + # valid ip address + gateway = mysensors.AsyncTCPGateway( + device, port=tcp_port, loop=hass.loop, event_callback=None, + persistence=persistence, persistence_file=persistence_file, + protocol_version=version) + except vol.Invalid: + # invalid ip address + return None + gateway.metric = hass.config.units.is_metric + gateway.optimistic = conf[CONF_OPTIMISTIC] + gateway.device = device + gateway.event_callback = _gw_callback_factory(hass) + gateway.nodes_config = gateway_conf[CONF_NODES] + if persistence: + await gateway.start_persistence() + + return gateway + + +async def finish_setup(hass, gateways): + """Load any persistent devices and platforms and start gateway.""" + discover_tasks = [] + start_tasks = [] + for gateway in gateways.values(): + discover_tasks.append(_discover_persistent_devices(hass, gateway)) + start_tasks.append(_gw_start(hass, gateway)) + if discover_tasks: + # Make sure all devices and platforms are loaded before gateway start. + await asyncio.wait(discover_tasks, loop=hass.loop) + if start_tasks: + await asyncio.wait(start_tasks, loop=hass.loop) + + +async def _discover_persistent_devices(hass, gateway): + """Discover platforms for devices loaded via persistence file.""" + tasks = [] + new_devices = defaultdict(list) + for node_id in gateway.sensors: + node = gateway.sensors[node_id] + for child in node.children.values(): + validated = _validate_child(gateway, node_id, child) + for platform, dev_ids in validated.items(): + new_devices[platform].extend(dev_ids) + for platform, dev_ids in new_devices.items(): + tasks.append(_discover_mysensors_platform(hass, platform, dev_ids)) + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + +@callback +def _discover_mysensors_platform(hass, platform, new_devices): + """Discover a MySensors platform.""" + task = hass.async_add_job(discovery.async_load_platform( + hass, platform, DOMAIN, + {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN})) + return task + + +async def _gw_start(hass, gateway): + """Start the gateway.""" + @callback + def gw_stop(event): + """Trigger to stop the gateway.""" + hass.async_add_job(gateway.stop()) + + await gateway.start() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) + if gateway.device == 'mqtt': + # Gatways connected via mqtt doesn't send gateway ready message. + return + gateway_ready = asyncio.Future() + gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) + hass.data[gateway_ready_key] = gateway_ready + + try: + with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop): + await gateway_ready + except asyncio.TimeoutError: + _LOGGER.warning( + "Gateway %s not ready after %s secs so continuing with setup", + gateway.device, GATEWAY_READY_TIMEOUT) + finally: + hass.data.pop(gateway_ready_key, None) + + +def _gw_callback_factory(hass): + """Return a new callback for the gateway.""" + @callback + def mysensors_callback(msg): + """Handle messages from a MySensors gateway.""" + start = timer() + _LOGGER.debug( + "Node update: node %s child %s", msg.node_id, msg.child_id) + + _set_gateway_ready(hass, msg) + + try: + child = msg.gateway.sensors[msg.node_id].children[msg.child_id] + except KeyError: + _LOGGER.debug("Not a child update for node %s", msg.node_id) + return + + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = _validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(SIGNAL_CALLBACK.format(*dev_id)) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + _discover_mysensors_platform(hass, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + async_dispatcher_send(hass, signal) + end = timer() + if end - start > 0.1: + _LOGGER.debug( + "Callback for node %s child %s took %.3f seconds", + msg.node_id, msg.child_id, end - start) + return mysensors_callback + + +@callback +def _set_gateway_ready(hass, msg): + """Set asyncio future result if gateway is ready.""" + if (msg.type != msg.gateway.const.MessageType.internal or + msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): + return + gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( + id(msg.gateway))) + if gateway_ready is None or gateway_ready.cancelled(): + return + gateway_ready.set_result(True) + + +def _validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index c6a3dcf9c9a..fc407de0a6b 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,8 +17,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.6.zip' - '#pybotvac==0.0.6'] +REQUIREMENTS = ['pybotvac==0.0.7'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' @@ -55,7 +54,12 @@ ACTION = { 7: 'Updating...', 8: 'Copying logs...', 9: 'Calculating position...', - 10: 'IEC test' + 10: 'IEC test', + 11: 'Map cleaning', + 12: 'Exploring map (creating a persistent map)', + 13: 'Acquiring Persistent Map IDs', + 14: 'Creating & Uploading Map', + 15: 'Suspended Exploration' } ERRORS = { @@ -71,12 +75,30 @@ ERRORS = { 'ui_error_navigation_pathproblems_returninghome': 'Cannot return to base', 'ui_error_navigation_falling': 'Clear my path', 'ui_error_picked_up': 'Picked up', - 'ui_error_stuck': 'Stuck!' + 'ui_error_stuck': 'Stuck!', + 'dustbin_full': 'Dust bin full', + 'dustbin_missing': 'Dust bin missing', + 'maint_brush_stuck': 'Brush stuck', + 'maint_brush_overload': 'Brush overloaded', + 'maint_bumper_stuck': 'Bumper stuck', + 'maint_vacuum_stuck': 'Vacuum is stuck', + 'maint_left_drop_stuck': 'Vacuum is stuck', + 'maint_left_wheel_stuck': 'Vacuum is stuck', + 'maint_right_drop_stuck': 'Vacuum is stuck', + 'maint_right_wheel_stuck': 'Vacuum is stuck', + 'not_on_charge_base': 'Not on the charge base', + 'nav_robot_falling': 'Clear my path', + 'nav_no_path': 'Clear my path', + 'nav_path_problem': 'Clear my path' } ALERTS = { 'ui_alert_dust_bin_full': 'Please empty dust bin', - 'ui_alert_recovering_location': 'Returning to start' + 'ui_alert_recovering_location': 'Returning to start', + 'dustbin_full': 'Please empty dust bin', + 'maint_brush_change': 'Change the brush', + 'maint_filter_change': 'Change the filter', + 'clean_completed_to_start': 'Cleaning completed' } @@ -122,7 +144,7 @@ class NeatoHub(object): _LOGGER.error("Unable to connect to Neato API") return False - @Throttle(timedelta(seconds=60)) + @Throttle(timedelta(seconds=300)) def update_robots(self): """Update the robot states.""" _LOGGER.debug("Running HUB.update_robots %s", diff --git a/homeassistant/components/nest/.translations/cs.json b/homeassistant/components/nest/.translations/cs.json new file mode 100644 index 00000000000..c884226174b --- /dev/null +++ b/homeassistant/components/nest/.translations/cs.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "M\u016f\u017eete nastavit pouze jeden Nest \u00fa\u010det.", + "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00ed URL vypr\u0161el", + "no_flows": "Pot\u0159ebujete nakonfigurovat Nest, abyste se s n\u00edm mohli autentizovat. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Intern\u00ed chyba ov\u011b\u0159en\u00ed k\u00f3du", + "invalid_code": "Neplatn\u00fd k\u00f3d", + "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el", + "unknown": "Nezn\u00e1m\u00e1 chyba ov\u011b\u0159en\u00ed k\u00f3du" + }, + "step": { + "init": { + "data": { + "flow_impl": "Poskytovatel" + }, + "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159en\u00ed chcete ov\u011b\u0159it slu\u017ebu Nest.", + "title": "Poskytovatel ov\u011b\u0159en\u00ed" + }, + "link": { + "data": { + "code": "K\u00f3d PIN" + }, + "description": "Chcete-li propojit \u00fa\u010det Nest, [autorizujte sv\u016fj \u00fa\u010det]({url}). \n\n Po autorizaci zkop\u00edrujte n\u00ed\u017ee uveden\u00fd k\u00f3d PIN.", + "title": "Propojit s Nest \u00fa\u010dtem" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json new file mode 100644 index 00000000000..721eafa807f --- /dev/null +++ b/homeassistant/components/nest/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "init": { + "data": { + "flow_impl": "Anbieter" + }, + "description": "W\u00e4hlen Sie, \u00fcber welchen Authentifizierungsanbieter Sie sich bei Nest authentifizieren m\u00f6chten.", + "title": "Authentifizierungsanbieter" + }, + "link": { + "data": { + "code": "PIN Code" + }, + "description": "[Autorisieren Sie ihr Konto] ( {url} ), um ihren Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcgen Sie anschlie\u00dfend den erhaltenen PIN Code hier ein.", + "title": "Nest-Konto verkn\u00fcpfen" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json new file mode 100644 index 00000000000..abf8f79599f --- /dev/null +++ b/homeassistant/components/nest/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d" + }, + "step": { + "init": { + "data": { + "flow_impl": "Szolg\u00e1ltat\u00f3" + } + }, + "link": { + "data": { + "code": "PIN-k\u00f3d" + } + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/it.json b/homeassistant/components/nest/.translations/it.json new file mode 100644 index 00000000000..ca34179cf5b --- /dev/null +++ b/homeassistant/components/nest/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "init": { + "title": "Fornitore di autenticazione" + }, + "link": { + "data": { + "code": "Codice PIN" + }, + "description": "Per collegare l'account Nido, [autorizzare l'account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito di seguito.", + "title": "Collega un account Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json new file mode 100644 index 00000000000..197cc8206d0 --- /dev/null +++ b/homeassistant/components/nest/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.", + "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code", + "invalid_code": "Ong\u00ebltege Code", + "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code", + "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Nest verbanne soll.", + "title": "Authentifikatioun Ubidder" + }, + "link": { + "data": { + "code": "Pin code" + }, + "description": "Fir den Nest Kont ze verbannen, [autoris\u00e9iert \u00e4ren Kont]({url}).\nKop\u00e9iert no der Autorisatioun den Pin hei \u00ebnnendr\u00ebnner", + "title": "Nest Kont verbannen" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/nl.json b/homeassistant/components/nest/.translations/nl.json new file mode 100644 index 00000000000..756eb07189a --- /dev/null +++ b/homeassistant/components/nest/.translations/nl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Je kunt slechts \u00e9\u00e9n Nest-account configureren.", + "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", + "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url.", + "no_flows": "U moet Nest configureren voordat u zich ermee kunt authenticeren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne foutvalidatiecode", + "invalid_code": "Ongeldige code", + "timeout": "Time-out validatie van code", + "unknown": "Onbekende foutvalidatiecode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Leverancier" + }, + "description": "Kies met welke authenticatieleverancier u wilt verifi\u00ebren met Nest.", + "title": "Authenticatieleverancier" + }, + "link": { + "data": { + "code": "Pincode" + }, + "description": "Als je je Nest-account wilt koppelen, [autoriseer je account] ( {url} ). \n\nNa autorisatie, kopieer en plak de voorziene pincode hieronder.", + "title": "Koppel Nest-account" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/sl.json b/homeassistant/components/nest/.translations/sl.json new file mode 100644 index 00000000000..d038ed4157f --- /dev/null +++ b/homeassistant/components/nest/.translations/sl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Nest.", + "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Nest. [Preberite navodila](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Notranja napaka pri preverjanju kode", + "invalid_code": "Neveljavna koda", + "timeout": "\u010casovna omejitev je potekla pri preverjanju kode", + "unknown": "Neznana napaka pri preverjanju kode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Nest.", + "title": "Ponudnik za preverjanje pristnosti" + }, + "link": { + "data": { + "code": "PIN koda" + }, + "description": "\u010ce \u017eelite povezati svoj ra\u010dun Nest, [pooblastite svoj ra\u010dun]({url}). \n\n Po odobritvi kopirajte in prilepite podano kodo PIN.", + "title": "Pove\u017eite Nest ra\u010dun" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/zh-Hant.json b/homeassistant/components/nest/.translations/zh-Hant.json new file mode 100644 index 00000000000..6b9dbdb19b1 --- /dev/null +++ b/homeassistant/components/nest/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Nest \u5e33\u865f\u3002", + "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Nest \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/nest/\uff09\u3002" + }, + "error": { + "internal_error": "\u8a8d\u8b49\u78bc\u5167\u90e8\u932f\u8aa4", + "invalid_code": "\u8a8d\u8b49\u78bc\u7121\u6548", + "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", + "unknown": "\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Nest \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002", + "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "link": { + "data": { + "code": "PIN \u78bc" + }, + "description": "\u6b32\u9023\u7d50 Nest \u5e33\u865f\uff0c[\u8a8d\u8b49\u5e33\u865f]({url}).\n\n\u65bc\u8a8d\u8b49\u5f8c\uff0c\u8907\u88fd\u4e26\u8cbc\u4e0a\u4e0b\u65b9\u7684\u8a8d\u8b49\u78bc\u3002", + "title": "\u9023\u7d50 Nest \u5e33\u865f" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear_lte.py b/homeassistant/components/netgear_lte.py index 4887ea1aa67..23a01d37c2b 100644 --- a/homeassistant/components/netgear_lte.py +++ b/homeassistant/components/netgear_lte.py @@ -9,12 +9,15 @@ from datetime import timedelta import voluptuous as vol import attr +import aiohttp -from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util import Throttle -REQUIREMENTS = ['eternalegypt==0.0.1'] +REQUIREMENTS = ['eternalegypt==0.0.2'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -30,33 +33,34 @@ CONFIG_SCHEMA = vol.Schema({ @attr.s -class LTEData: - """Class for LTE state.""" +class ModemData: + """Class for modem state.""" - eternalegypt = attr.ib() + modem = attr.ib() unread_count = attr.ib(init=False) usage = attr.ib(init=False) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Call the API to update the data.""" - information = await self.eternalegypt.information() + information = await self.modem.information() self.unread_count = sum(1 for x in information.sms if x.unread) self.usage = information.usage @attr.s -class LTEHostData: - """Container for LTE states.""" +class LTEData: + """Shared state.""" - hostdata = attr.ib(init=False, factory=dict) + websession = attr.ib() + modem_data = attr.ib(init=False, factory=dict) - def get(self, config): - """Get the requested or the only hostdata value.""" + def get_modem_data(self, config): + """Get the requested or the only modem_data value.""" if CONF_HOST in config: - return self.hostdata.get(config[CONF_HOST]) - elif len(self.hostdata) == 1: - return next(iter(self.hostdata.values())) + return self.modem_data.get(config[CONF_HOST]) + elif len(self.modem_data) == 1: + return next(iter(self.modem_data.values())) return None @@ -64,7 +68,9 @@ class LTEHostData: async def async_setup(hass, config): """Set up Netgear LTE component.""" if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = LTEHostData() + websession = async_create_clientsession( + hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + hass.data[DATA_KEY] = LTEData(websession) tasks = [_setup_lte(hass, conf) for conf in config.get(DOMAIN, [])] if tasks: @@ -80,7 +86,17 @@ async def _setup_lte(hass, lte_config): host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] - eternalegypt = eternalegypt.LB2120(host, password) - lte_data = LTEData(eternalegypt) - await lte_data.async_update() - hass.data[DATA_KEY].hostdata[host] = lte_data + websession = hass.data[DATA_KEY].websession + + modem = eternalegypt.Modem(hostname=host, websession=websession) + await modem.login(password=password) + + modem_data = ModemData(modem) + await modem_data.async_update() + hass.data[DATA_KEY].modem_data[host] = modem_data + + async def cleanup(event): + """Clean up resources.""" + await modem.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index b0cc4a0121d..46ac2f89d33 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -44,7 +44,6 @@ def get_service(hass, config, discovery_info=None): context_b64 = base64.b64encode(context_str.encode('utf-8')) context = context_b64.decode('utf-8') - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index c94e3abaa96..7ecf5a7cc7f 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -35,7 +35,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the AWS SNS notification service.""" - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 43c04ed16d0..30b673846e7 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -34,7 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the AWS SQS notification service.""" - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/ciscospark.py b/homeassistant/components/notify/ciscospark.py index 0bf184023d7..e83e0e9024f 100644 --- a/homeassistant/components/notify/ciscospark.py +++ b/homeassistant/components/notify/ciscospark.py @@ -25,7 +25,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the CiscoSpark notification service.""" return CiscoSparkNotificationService( diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index c718149b4b5..31e4c4751c8 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components import ecobee from homeassistant.components.notify import ( - BaseNotificationService, PLATFORM_SCHEMA) # NOQA + BaseNotificationService, PLATFORM_SCHEMA) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7ccf4f8db90..7529608387d 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -413,7 +413,6 @@ class HTML5NotificationService(BaseNotificationService): json.dumps(payload), gcm_key=gcm_key, ttl='86400' ) - # pylint: disable=no-member if response.status_code == 410: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py index e391d6559e5..a75ff9cd165 100644 --- a/homeassistant/components/notify/joaoapps_join.py +++ b/homeassistant/components/notify/joaoapps_join.py @@ -28,7 +28,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Join notification service.""" api_key = config.get(CONF_API_KEY) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index f6c3e152b0a..0cc3a0213b3 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -36,7 +36,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the LaMetric notification service.""" hlmn = hass.data.get(LAMETRIC_DOMAIN) @@ -59,7 +58,6 @@ class LaMetricNotificationService(BaseNotificationService): self._priority = priority self._devices = [] - # pylint: disable=broad-except def send_message(self, message="", **kwargs): """Send a message to some LaMetric device.""" from lmnotify import SimpleFrame, Sound, Model diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index db568514dea..71ce7fb0b74 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -18,7 +18,7 @@ async def async_get_service(hass, config, discovery_info=None): return MySensorsNotificationService(hass) -class MySensorsNotificationDevice(mysensors.MySensorsDevice): +class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): diff --git a/homeassistant/components/notify/netgear_lte.py b/homeassistant/components/notify/netgear_lte.py index b4ed53b828d..97dfe504a51 100644 --- a/homeassistant/components/notify/netgear_lte.py +++ b/homeassistant/components/notify/netgear_lte.py @@ -25,16 +25,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_get_service(hass, config, discovery_info=None): """Get the notification service.""" - lte_data = hass.data[DATA_KEY].get(config) + modem_data = hass.data[DATA_KEY].get_modem_data(config) phone = config.get(ATTR_TARGET) - return NetgearNotifyService(lte_data, phone) + return NetgearNotifyService(modem_data, phone) @attr.s class NetgearNotifyService(BaseNotificationService): """Implementation of a notification service.""" - lte_data = attr.ib() + modem_data = attr.ib() phone = attr.ib() async def async_send_message(self, message="", **kwargs): @@ -42,4 +42,4 @@ class NetgearNotifyService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET, self.phone) if targets and message: for target in targets: - await self.lte_data.eternalegypt.sms(target, message) + await self.modem_data.modem.sms(target, message) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index cd73bbba4bf..3ec0b27e7c4 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -26,7 +26,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Pushover notification service.""" from pushover import InitError diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b73f3a17ee7..92b709af8ad 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT, CONTENT_TYPE_TEXT_PLAIN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.4.0'] +REQUIREMENTS = ['sendgrid==5.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index b50260e4c61..d4c5a196a3f 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -44,7 +44,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" import slacker diff --git a/homeassistant/components/nuimo_controller.py b/homeassistant/components/nuimo_controller.py index 25e8a230224..e7ab86a5f35 100644 --- a/homeassistant/components/nuimo_controller.py +++ b/homeassistant/components/nuimo_controller.py @@ -15,9 +15,7 @@ from homeassistant.const import (CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP) REQUIREMENTS = [ '--only-binary=all ' # avoid compilation of gattlib - 'https://github.com/getSenic/nuimo-linux-python' - '/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip' - '#nuimo==1.0.0'] + 'nuimo==0.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 5caaa1b372d..c1059227f7a 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -144,7 +144,6 @@ class OctoPrintAPI(object): return response -# pylint: disable=unused-variable def get_value_from_json(json_dict, sensor_type, group, tool): """Return the value for sensor_type from the JSON.""" if group not in json_dict: diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 96ed098567d..6f233dafe08 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -11,16 +11,15 @@ import voluptuous as vol from aiohttp import web from homeassistant.components.http import HomeAssistantView -from homeassistant.components import recorder from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore -from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import entityfilter, state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.1.0'] +REQUIREMENTS = ['prometheus_client==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -29,8 +28,14 @@ API_ENDPOINT = '/api/prometheus' DOMAIN = 'prometheus' DEPENDENCIES = ['http'] +CONF_FILTER = 'filter' +CONF_PROM_NAMESPACE = 'namespace' + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: recorder.FILTER_SCHEMA, + DOMAIN: vol.All({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_PROM_NAMESPACE): cv.string, + }) }, extra=vol.ALLOW_EXTRA) @@ -40,25 +45,26 @@ def setup(hass, config): hass.http.register_view(PrometheusView(prometheus_client)) - conf = config.get(DOMAIN, {}) - exclude = conf.get(CONF_EXCLUDE, {}) - include = conf.get(CONF_INCLUDE, {}) - metrics = Metrics(prometheus_client, exclude, include) + conf = config[DOMAIN] + entity_filter = conf[CONF_FILTER] + namespace = conf.get(CONF_PROM_NAMESPACE) + metrics = PrometheusMetrics(prometheus_client, entity_filter, namespace) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) return True -class Metrics(object): +class PrometheusMetrics(object): """Model all of the metrics which should be exposed to Prometheus.""" - def __init__(self, prometheus_client, exclude, include): + def __init__(self, prometheus_client, entity_filter, namespace): """Initialize Prometheus Metrics.""" self.prometheus_client = prometheus_client - self.exclude = exclude.get(CONF_ENTITIES, []) + \ - exclude.get(CONF_DOMAINS, []) - self.include_domains = include.get(CONF_DOMAINS, []) - self.include_entities = include.get(CONF_ENTITIES, []) + self._filter = entity_filter + if namespace: + self.metrics_prefix = "{}_".format(namespace) + else: + self.metrics_prefix = "" self._metrics = {} def handle_event(self, event): @@ -71,14 +77,7 @@ class Metrics(object): _LOGGER.debug("Handling state update for %s", entity_id) domain, _ = hacore.split_entity_id(entity_id) - if entity_id in self.exclude: - return - if domain in self.exclude and entity_id not in self.include_entities: - return - if self.include_domains and domain not in self.include_domains: - return - if not self.exclude and (self.include_entities and - entity_id not in self.include_entities): + if not self._filter(state.entity_id): return handler = '_handle_{}'.format(domain) @@ -100,7 +99,9 @@ class Metrics(object): try: return self._metrics[metric] except KeyError: - self._metrics[metric] = factory(metric, documentation, labels) + full_metric_name = "{}{}".format(self.metrics_prefix, metric) + self._metrics[metric] = factory( + full_metric_name, documentation, labels) return self._metrics[metric] @staticmethod diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py new file mode 100644 index 00000000000..b3b2d05e933 --- /dev/null +++ b/homeassistant/components/rachio.py @@ -0,0 +1,289 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rachio/ +""" +import asyncio +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.auth import generate_secret +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['rachiopy==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'rachio' + +CONF_CUSTOM_URL = 'hass_url_override' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_CUSTOM_URL): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +# Keys used in the API JSON +KEY_DEVICE_ID = 'deviceId' +KEY_DEVICES = 'devices' +KEY_ENABLED = 'enabled' +KEY_EXTERNAL_ID = 'externalId' +KEY_ID = 'id' +KEY_NAME = 'name' +KEY_ON = 'on' +KEY_STATUS = 'status' +KEY_SUBTYPE = 'subType' +KEY_SUMMARY = 'summary' +KEY_TYPE = 'type' +KEY_URL = 'url' +KEY_USERNAME = 'username' +KEY_ZONE_ID = 'zoneId' +KEY_ZONE_NUMBER = 'zoneNumber' +KEY_ZONES = 'zones' + +STATUS_ONLINE = 'ONLINE' +STATUS_OFFLINE = 'OFFLINE' + +# Device webhook values +TYPE_CONTROLLER_STATUS = 'DEVICE_STATUS' +SUBTYPE_OFFLINE = 'OFFLINE' +SUBTYPE_ONLINE = 'ONLINE' +SUBTYPE_OFFLINE_NOTIFICATION = 'OFFLINE_NOTIFICATION' +SUBTYPE_COLD_REBOOT = 'COLD_REBOOT' +SUBTYPE_SLEEP_MODE_ON = 'SLEEP_MODE_ON' +SUBTYPE_SLEEP_MODE_OFF = 'SLEEP_MODE_OFF' +SUBTYPE_BROWNOUT_VALVE = 'BROWNOUT_VALVE' +SUBTYPE_RAIN_SENSOR_DETECTION_ON = 'RAIN_SENSOR_DETECTION_ON' +SUBTYPE_RAIN_SENSOR_DETECTION_OFF = 'RAIN_SENSOR_DETECTION_OFF' +SUBTYPE_RAIN_DELAY_ON = 'RAIN_DELAY_ON' +SUBTYPE_RAIN_DELAY_OFF = 'RAIN_DELAY_OFF' + +# Schedule webhook values +TYPE_SCHEDULE_STATUS = 'SCHEDULE_STATUS' +SUBTYPE_SCHEDULE_STARTED = 'SCHEDULE_STARTED' +SUBTYPE_SCHEDULE_STOPPED = 'SCHEDULE_STOPPED' +SUBTYPE_SCHEDULE_COMPLETED = 'SCHEDULE_COMPLETED' +SUBTYPE_WEATHER_NO_SKIP = 'WEATHER_INTELLIGENCE_NO_SKIP' +SUBTYPE_WEATHER_SKIP = 'WEATHER_INTELLIGENCE_SKIP' +SUBTYPE_WEATHER_CLIMATE_SKIP = 'WEATHER_INTELLIGENCE_CLIMATE_SKIP' +SUBTYPE_WEATHER_FREEZE = 'WEATHER_INTELLIGENCE_FREEZE' + +# Zone webhook values +TYPE_ZONE_STATUS = 'ZONE_STATUS' +SUBTYPE_ZONE_STARTED = 'ZONE_STARTED' +SUBTYPE_ZONE_STOPPED = 'ZONE_STOPPED' +SUBTYPE_ZONE_COMPLETED = 'ZONE_COMPLETED' +SUBTYPE_ZONE_CYCLING = 'ZONE_CYCLING' +SUBTYPE_ZONE_CYCLING_COMPLETED = 'ZONE_CYCLING_COMPLETED' + +# Webhook callbacks +LISTEN_EVENT_TYPES = ['DEVICE_STATUS_EVENT', 'ZONE_STATUS_EVENT'] +WEBHOOK_CONST_ID = 'homeassistant.rachio:' +WEBHOOK_PATH = URL_API + DOMAIN +SIGNAL_RACHIO_UPDATE = DOMAIN + '_update' +SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + '_controller' +SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + '_zone' +SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + '_schedule' + + +def setup(hass, config) -> bool: + """Set up the Rachio component.""" + from rachiopy import Rachio + + # Listen for incoming webhook connections + hass.http.register_view(RachioWebhookView()) + + # Configure API + api_key = config[DOMAIN].get(CONF_API_KEY) + rachio = Rachio(api_key) + + # Get the URL of this server + custom_url = config[DOMAIN].get(CONF_CUSTOM_URL) + hass_url = hass.config.api.base_url if custom_url is None else custom_url + rachio.webhook_auth = generate_secret() + rachio.webhook_url = hass_url + WEBHOOK_PATH + + # Get the API user + try: + person = RachioPerson(hass, rachio) + except AssertionError as error: + _LOGGER.error("Could not reach the Rachio API: %s", error) + return False + + # Check for Rachio controller devices + if not person.controllers: + _LOGGER.error("No Rachio devices found in account %s", + person.username) + return False + else: + _LOGGER.info("%d Rachio device(s) found", len(person.controllers)) + + # Enable component + hass.data[DOMAIN] = person + return True + + +class RachioPerson(object): + """Represent a Rachio user.""" + + def __init__(self, hass, rachio): + """Create an object from the provided API instance.""" + # Use API token to get user ID + self._hass = hass + self.rachio = rachio + + response = rachio.person.getInfo() + assert int(response[0][KEY_STATUS]) == 200, "API key error" + self._id = response[1][KEY_ID] + + # Use user ID to get user data + data = rachio.person.get(self._id) + assert int(data[0][KEY_STATUS]) == 200, "User ID error" + self.username = data[1][KEY_USERNAME] + self._controllers = [RachioIro(self._hass, self.rachio, controller) + for controller in data[1][KEY_DEVICES]] + _LOGGER.info('Using Rachio API as user "%s"', self.username) + + @property + def user_id(self) -> str: + """Get the user ID as defined by the Rachio API.""" + return self._id + + @property + def controllers(self) -> list: + """Get a list of controllers managed by this account.""" + return self._controllers + + +class RachioIro(object): + """Represent a Rachio Iro.""" + + def __init__(self, hass, rachio, data): + """Initialize a Rachio device.""" + self.hass = hass + self.rachio = rachio + self._id = data[KEY_ID] + self._name = data[KEY_NAME] + self._zones = data[KEY_ZONES] + self._init_data = data + _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) + + # Listen for all updates + self._init_webhooks() + + def _init_webhooks(self) -> None: + """Start getting updates from the Rachio API.""" + current_webhook_id = None + + # First delete any old webhooks that may have stuck around + def _deinit_webhooks(event) -> None: + """Stop getting updates from the Rachio API.""" + webhooks = self.rachio.notification.getDeviceWebhook( + self.controller_id)[1] + for webhook in webhooks: + if webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) or\ + webhook[KEY_ID] == current_webhook_id: + self.rachio.notification.deleteWebhook(webhook[KEY_ID]) + _deinit_webhooks(None) + + # Choose which events to listen for and get their IDs + event_types = [] + for event_type in self.rachio.notification.getWebhookEventType()[1]: + if event_type[KEY_NAME] in LISTEN_EVENT_TYPES: + event_types.append({"id": event_type[KEY_ID]}) + + # Register to listen to these events from the device + url = self.rachio.webhook_url + auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth + new_webhook = self.rachio.notification.postWebhook(self.controller_id, + auth, url, + event_types) + # Save ID for deletion at shutdown + current_webhook_id = new_webhook[1][KEY_ID] + self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks) + + def __str__(self) -> str: + """Display the controller as a string.""" + return 'Rachio controller "{}"'.format(self.name) + + @property + def controller_id(self) -> str: + """Return the Rachio API controller ID.""" + return self._id + + @property + def name(self) -> str: + """Return the user-defined name of the controller.""" + return self._name + + @property + def current_schedule(self) -> str: + """Return the schedule that the device is running right now.""" + return self.rachio.device.getCurrentSchedule(self.controller_id)[1] + + @property + def init_data(self) -> dict: + """Return the information used to set up the controller.""" + return self._init_data + + def list_zones(self, include_disabled=False) -> list: + """Return a list of the zone dicts connected to the device.""" + # All zones + if include_disabled: + return self._zones + + # Only enabled zones + return [z for z in self._zones if z[KEY_ENABLED]] + + def get_zone(self, zone_id) -> dict or None: + """Return the zone with the given ID.""" + for zone in self.list_zones(include_disabled=True): + if zone[KEY_ID] == zone_id: + return zone + + return None + + def stop_watering(self) -> None: + """Stop watering all zones connected to this controller.""" + self.rachio.device.stopWater(self.controller_id) + _LOGGER.info("Stopped watering of all zones on %s", str(self)) + + +class RachioWebhookView(HomeAssistantView): + """Provide a page for the server to call.""" + + SIGNALS = { + TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE, + TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE, + TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE, + } + + requires_auth = False # Handled separately + url = WEBHOOK_PATH + name = url[1:].replace('/', ':') + + # pylint: disable=no-self-use + @asyncio.coroutine + async def post(self, request) -> web.Response: + """Handle webhook calls from the server.""" + hass = request.app['hass'] + data = await request.json() + + try: + auth = data.get(KEY_EXTERNAL_ID, str()).split(':')[1] + assert auth == hass.data[DOMAIN].rachio.webhook_auth + except (AssertionError, IndexError): + return web.Response(status=web.HTTPForbidden.status_code) + + update_type = data[KEY_TYPE] + if update_type in self.SIGNALS: + async_dispatcher_send(hass, self.SIGNALS[update_type], data) + + return web.Response(status=web.HTTPNoContent.status_code) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 8a3e51b55b3..59a2dc861a6 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -229,7 +229,6 @@ class XiaomiMiioRemote(RemoteDevice): return {'hidden': 'true'} return - # pylint: disable=R0201 @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the device on.""" diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 2f170a20646..afe777ff7cc 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -162,7 +162,6 @@ def get_pt2262_cmd(device_id, data_bits): return hex(data[-1] & mask) -# pylint: disable=unused-variable def get_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for device in RFX_DEVICES.values(): @@ -176,7 +175,6 @@ def get_pt2262_device(device_id): return None -# pylint: disable=unused-variable def find_possible_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for dev_id, device in RFX_DEVICES.items(): diff --git a/homeassistant/components/rpi_gpio.py b/homeassistant/components/rpi_gpio.py index dfc60b5e45e..5cb7bb337ce 100644 --- a/homeassistant/components/rpi_gpio.py +++ b/homeassistant/components/rpi_gpio.py @@ -17,7 +17,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'rpi_gpio' -# pylint: disable=no-member def setup(hass, config): """Set up the Raspberry PI GPIO component.""" import RPi.GPIO as GPIO diff --git a/homeassistant/components/satel_integra.py b/homeassistant/components/satel_integra.py index 4b61ff15c08..4247855da39 100644 --- a/homeassistant/components/satel_integra.py +++ b/homeassistant/components/satel_integra.py @@ -4,7 +4,6 @@ Support for Satel Integra devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/satel_integra/ """ -# pylint: disable=invalid-name import asyncio import logging diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 38d2226012c..bd23b9850f7 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -121,7 +121,6 @@ class BitcoinSensor(Entity): stats = self.data.stats ticker = self.data.ticker - # pylint: disable=no-member if self.type == 'exchangerate': self._state = ticker[self._currency].p15min self._unit_of_measurement = self._currency diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 590d5a8f1ce..10a96ded437 100644 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -287,7 +287,6 @@ class BrSensor(Entity): img = condition.get(IMAGE, None) - # pylint: disable=protected-access if new_state != self._state or img != self._entity_picture: self._state = new_state self._entity_picture = img @@ -299,12 +298,10 @@ class BrSensor(Entity): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - # pylint: disable=protected-access self._state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:]) return True # update all other sensors - # pylint: disable=protected-access self._state = data.get(self.type) return True @@ -329,7 +326,7 @@ class BrSensor(Entity): return self._state @property - def should_poll(self): # pylint: disable=no-self-use + def should_poll(self): """No polling needed.""" return False diff --git a/homeassistant/components/sensor/cpuspeed.py b/homeassistant/components/sensor/cpuspeed.py index c39ae43aef0..c6a7106663f 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -30,7 +30,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the CPU speed sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/cups.py b/homeassistant/components/sensor/cups.py index 7c1d9fc3d49..6d55853d724 100644 --- a/homeassistant/components/sensor/cups.py +++ b/homeassistant/components/sensor/cups.py @@ -128,7 +128,7 @@ class CupsSensor(Entity): self._printer = self.data.printers.get(self._name) -# pylint: disable=import-error, no-name-in-module +# pylint: disable=no-name-in-module class CupsData(object): """Get the latest data from CUPS and update the state.""" diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index cbf06783dc7..b22e4df9a50 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,10 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit -# Update this requirement to upstream as soon as it supports Python 3. -REQUIREMENTS = ['https://github.com/adafruit/Adafruit_Python_DHT/archive/' - 'da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip' - '#Adafruit_DHT==1.3.2'] +REQUIREMENTS = ['Adafruit_Python_DHT==1.3.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py index 9105e30eb42..e023dfcc49f 100644 --- a/homeassistant/components/sensor/dwd_weather_warnings.py +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -95,7 +95,6 @@ class DwdWeatherWarningsSensor(Entity): """Return the unit the value is expressed in.""" return self._var_units - # pylint: disable=no-member @property def state(self): """Return the state of the device.""" @@ -104,7 +103,6 @@ class DwdWeatherWarningsSensor(Entity): except TypeError: return self._api.data[self._var_id] - # pylint: disable=no-member @property def device_state_attributes(self): """Return the state attributes of the DWD-Weather-Warnings.""" diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 157f366c0c4..cca06bd9782 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -34,7 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable, too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Dweet sensor.""" import dweepy diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index b11dae8e168..265350f3e95 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -55,7 +55,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sense HAT sensor platform.""" try: - # pylint: disable=import-error import envirophat except OSError: _LOGGER.error("No Enviro pHAT was found.") @@ -175,7 +174,6 @@ class EnvirophatData(object): self.light_red, self.light_green, self.light_blue = \ self.envirophat.light.rgb() if self.use_leds: - # pylint: disable=no-value-for-parameter self.envirophat.leds.off() # accelerometer readings in G diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index f312d1f22cc..87bd735a03d 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -225,7 +225,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, config, add_devices, config_path, discovery_info=None) return False else: - config_file = save_json(config_path, DEFAULT_CONFIG) + save_json(config_path, DEFAULT_CONFIG) request_app_setup( hass, config, add_devices, config_path, discovery_info=None) return False diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 4fed3793c50..bd6e91c7b53 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -56,7 +56,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Glances sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index 472dd1d70f6..1d270419933 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -86,7 +86,6 @@ class GpsdSensor(Entity): """Return the name.""" return self._name - # pylint: disable=no-member @property def state(self): """Return the state of GPSD.""" diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index ca8c19bbc7a..1048c04d43d 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.sensor import DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_WEATHER, diff --git a/homeassistant/components/sensor/kira.py b/homeassistant/components/sensor/kira.py index 74a1bd19d34..19566100f99 100644 --- a/homeassistant/components/sensor/kira.py +++ b/homeassistant/components/sensor/kira.py @@ -18,7 +18,6 @@ ICON = 'mdi:remote' CONF_SENSOR = 'sensor' -# pylint: disable=too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Kira sensor.""" if discovery_info is not None: diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index ee9ab146c87..6ee3f7d16d0 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -68,7 +68,6 @@ class LastfmSensor(Entity): """Return the state of the sensor.""" return self._state - # pylint: disable=no-member def update(self): """Update device state.""" self._cover = self._user.get_image() diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 8bf95d4ef6e..d888a6c634d 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -2,18 +2,18 @@ Support for Loop Energy sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.loop_energy/ +https://home-assistant.io/components/sensor.loopenergy/ """ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.const import ( - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -47,12 +47,12 @@ GAS_SCHEMA = vol.Schema({ vol.Optional(CONF_GAS_TYPE, default=CONF_UNIT_SYSTEM_METRIC): GAS_TYPE_SCHEMA, vol.Optional(CONF_GAS_CALORIFIC, default=DEFAULT_CALORIFIC): - vol.Coerce(float) + vol.Coerce(float), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ELEC): ELEC_SCHEMA, - vol.Optional(CONF_GAS): GAS_SCHEMA + vol.Optional(CONF_GAS): GAS_SCHEMA, }) @@ -63,7 +63,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elec_config = config.get(CONF_ELEC) gas_config = config.get(CONF_GAS, {}) - # pylint: disable=too-many-function-args controller = pyloopenergy.LoopEnergy( elec_config.get(CONF_ELEC_SERIAL), elec_config.get(CONF_ELEC_SECRET), diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index f6bec3284c3..ab6bd8270ce 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -49,7 +49,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up mFi sensors.""" host = config.get(CONF_HOST) diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index 1add4157f0e..2fbfc0e97a4 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -42,7 +42,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsSensor(mysensors.MySensorsEntity): +class MySensorsSensor(mysensors.device.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/sensor/netgear_lte.py b/homeassistant/components/sensor/netgear_lte.py index 859435edbc9..b4a3e2a1155 100644 --- a/homeassistant/components/sensor/netgear_lte.py +++ b/homeassistant/components/sensor/netgear_lte.py @@ -29,14 +29,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_setup_platform( hass, config, async_add_devices, discovery_info): """Set up Netgear LTE sensor devices.""" - lte_data = hass.data[DATA_KEY].get(config) + modem_data = hass.data[DATA_KEY].get_modem_data(config) sensors = [] for sensortype in config[CONF_SENSORS]: if sensortype == SENSOR_SMS: - sensors.append(SMSSensor(lte_data)) + sensors.append(SMSSensor(modem_data)) elif sensortype == SENSOR_USAGE: - sensors.append(UsageSensor(lte_data)) + sensors.append(UsageSensor(modem_data)) async_add_devices(sensors, True) @@ -45,11 +45,11 @@ async def async_setup_platform( class LTESensor(Entity): """Data usage sensor entity.""" - lte_data = attr.ib() + modem_data = attr.ib() async def async_update(self): """Update state.""" - await self.lte_data.async_update() + await self.modem_data.async_update() class SMSSensor(LTESensor): @@ -63,7 +63,7 @@ class SMSSensor(LTESensor): @property def state(self): """Return the state of the sensor.""" - return self.lte_data.unread_count + return self.modem_data.unread_count class UsageSensor(LTESensor): @@ -82,4 +82,4 @@ class UsageSensor(LTESensor): @property def state(self): """Return the state of the sensor.""" - return round(self.lte_data.usage / 1024**2, 1) + return round(self.modem_data.usage / 1024**2, 1) diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index 8e8c784e68b..2adf5691e2e 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pihole==0.1.2'] +REQUIREMENTS = ['hole==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, - default=list(MONITORED_CONDITIONS)): + default=['ads_blocked_today']): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) @@ -73,7 +73,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_setup_platform( hass, config, async_add_devices, discovery_info=None): """Set up the Pi-hole sensor.""" - from pihole import PiHole + from hole import Hole name = config.get(CONF_NAME) host = config.get(CONF_HOST) @@ -82,7 +82,7 @@ async def async_setup_platform( verify_tls = config.get(CONF_VERIFY_SSL) session = async_get_clientsession(hass) - pi_hole = PiHoleData(PiHole( + pi_hole = PiHoleData(Hole( host, hass.loop, session, location=location, tls=use_tls, verify_tls=verify_tls)) @@ -164,11 +164,11 @@ class PiHoleData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest data from the Pi-hole.""" - from pihole.exceptions import PiHoleError + from hole.exceptions import HoleError try: await self.api.get_data() self.available = True - except PiHoleError: + except HoleError: _LOGGER.error("Unable to fetch data from Pi-hole") self.available = False diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 1ef5a27cf3d..838358fcfca 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -13,17 +13,17 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS -) + ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle, slugify +from homeassistant.util import Throttle -REQUIREMENTS = ['pypollencom==1.1.2'] +REQUIREMENTS = ['pypollencom==2.1.0'] _LOGGER = logging.getLogger(__name__) -ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' -ATTR_ALLERGEN_NAME = 'primary_allergen_name' -ATTR_ALLERGEN_TYPE = 'primary_allergen_type' +ATTR_ALLERGEN_GENUS = 'allergen_genus' +ATTR_ALLERGEN_NAME = 'allergen_name' +ATTR_ALLERGEN_TYPE = 'allergen_type' ATTR_CITY = 'city' ATTR_OUTLOOK = 'outlook' ATTR_RATING = 'rating' @@ -34,53 +34,30 @@ ATTR_ZIP_CODE = 'zip_code' CONF_ZIP_CODE = 'zip_code' DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) -MIN_TIME_UPDATE_AVERAGES = timedelta(hours=12) -MIN_TIME_UPDATE_INDICES = timedelta(minutes=10) +TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted' +TYPE_ALLERGY_HISTORIC = 'allergy_average_historical' +TYPE_ALLERGY_INDEX = 'allergy_index' +TYPE_ALLERGY_OUTLOOK = 'allergy_outlook' +TYPE_ALLERGY_TODAY = 'allergy_index_today' +TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow' +TYPE_ALLERGY_YESTERDAY = 'allergy_index_yesterday' +TYPE_DISEASE_FORECAST = 'disease_average_forecasted' -CONDITIONS = { - 'allergy_average_forecasted': ( - 'Allergy Index: Forecasted Average', - 'AllergyAverageSensor', - 'allergy_average_data', - {'data_attr': 'extended_data'}, - 'mdi:flower' - ), - 'allergy_average_historical': ( - 'Allergy Index: Historical Average', - 'AllergyAverageSensor', - 'allergy_average_data', - {'data_attr': 'historic_data'}, - 'mdi:flower' - ), - 'allergy_index_today': ( - 'Allergy Index: Today', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Today'}, - 'mdi:flower' - ), - 'allergy_index_tomorrow': ( - 'Allergy Index: Tomorrow', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Tomorrow'}, - 'mdi:flower' - ), - 'allergy_index_yesterday': ( - 'Allergy Index: Yesterday', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Yesterday'}, - 'mdi:flower' - ), - 'disease_average_forecasted': ( - 'Cold & Flu: Forecasted Average', - 'AllergyAverageSensor', - 'disease_average_data', - {'data_attr': 'extended_data'}, - 'mdi:snowflake' - ) +SENSORS = { + TYPE_ALLERGY_FORECAST: ( + 'Allergy Index: Forecasted Average', None, 'mdi:flower', 'index'), + TYPE_ALLERGY_HISTORIC: ( + 'Allergy Index: Historical Average', None, 'mdi:flower', 'index'), + TYPE_ALLERGY_TODAY: ( + 'Allergy Index: Today', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_ALLERGY_TOMORROW: ( + 'Allergy Index: Tomorrow', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_ALLERGY_YESTERDAY: ( + 'Allergy Index: Yesterday', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_DISEASE_FORECAST: ( + 'Cold & Flu: Forecasted Average', None, 'mdi:snowflake', 'index') } RATING_MAPPING = [{ @@ -105,69 +82,69 @@ RATING_MAPPING = [{ 'maximum': 12 }] +TREND_FLAT = 'Flat' +TREND_INCREASING = 'Increasing' +TREND_SUBSIDING = 'Subsiding' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ZIP_CODE): str, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Configure the platform and add the sensors.""" from pypollencom import Client - _LOGGER.debug('Configuration data: %s', config) + websession = aiohttp_client.async_get_clientsession(hass) - client = Client(config[CONF_ZIP_CODE]) - datas = { - 'allergy_average_data': AllergyAveragesData(client), - 'allergy_index_data': AllergyIndexData(client), - 'disease_average_data': DiseaseData(client) - } - classes = { - 'AllergyAverageSensor': AllergyAverageSensor, - 'AllergyIndexSensor': AllergyIndexSensor - } + data = PollenComData( + Client(config[CONF_ZIP_CODE], websession), + config[CONF_MONITORED_CONDITIONS]) - for data in datas.values(): - data.update() + await data.async_update() sensors = [] - for condition in config[CONF_MONITORED_CONDITIONS]: - name, sensor_class, data_key, params, icon = CONDITIONS[condition] - sensors.append(classes[sensor_class]( - datas[data_key], - params, - name, - icon, - config[CONF_ZIP_CODE] - )) + for kind in config[CONF_MONITORED_CONDITIONS]: + name, category, icon, unit = SENSORS[kind] + sensors.append( + PollencomSensor( + data, config[CONF_ZIP_CODE], kind, category, name, icon, unit)) - add_devices(sensors, True) + async_add_devices(sensors, True) -def calculate_trend(list_of_nums): - """Calculate the most common rating as a trend.""" +def calculate_average_rating(indices): + """Calculate the human-friendly historical allergy average.""" ratings = list( - r['label'] for n in list_of_nums - for r in RATING_MAPPING + r['label'] for n in indices for r in RATING_MAPPING if r['minimum'] <= n <= r['maximum']) return max(set(ratings), key=ratings.count) -class BaseSensor(Entity): - """Define a base class for all of our sensors.""" +class PollencomSensor(Entity): + """Define a Pollen.com sensor.""" - def __init__(self, data, data_params, name, icon, unique_id): + def __init__(self, pollencom, zip_code, kind, category, name, icon, unit): """Initialize the sensor.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._category = category self._icon = icon self._name = name - self._data_params = data_params self._state = None - self._unit = None - self._unique_id = unique_id - self.data = data + self._type = kind + self._unit = unit + self._zip_code = zip_code + self.pollencom = pollencom + + @property + def available(self): + """Return True if entity is available.""" + return bool( + self.pollencom.data.get(self._type) + or self.pollencom.data.get(self._category)) @property def device_state_attributes(self): @@ -192,187 +169,161 @@ class BaseSensor(Entity): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}'.format(self._unique_id, slugify(self._name)) + return '{0}_{1}'.format(self._zip_code, self._type) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit - -class AllergyAverageSensor(BaseSensor): - """Define a sensor to show allergy average information.""" - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - try: - data_attr = getattr(self.data, self._data_params['data_attr']) - indices = [p['Index'] for p in data_attr['Location']['periods']] - self._attrs[ATTR_TREND] = calculate_trend(indices) - except KeyError: - _LOGGER.error("Pollen.com API didn't return any data") + async def async_update(self): + """Update the sensor.""" + await self.pollencom.async_update() + if not self.pollencom.data: return - try: - self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() - self._attrs[ATTR_STATE] = data_attr['Location']['State'] - self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] - except KeyError: - _LOGGER.debug('Location data not included in API response') - self._attrs[ATTR_CITY] = None - self._attrs[ATTR_STATE] = None - self._attrs[ATTR_ZIP_CODE] = None + if self._category: + data = self.pollencom.data[self._category]['Location'] + else: + data = self.pollencom.data[self._type]['Location'] + indices = [p['Index'] for p in data['periods']] average = round(mean(indices), 1) [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= average <= i['maximum'] ] - self._attrs[ATTR_RATING] = rating + slope = (data['periods'][-1]['Index'] - data['periods'][-2]['Index']) + trend = TREND_FLAT + if slope > 0: + trend = TREND_INCREASING + elif slope < 0: + trend = TREND_SUBSIDING - self._state = average - self._unit = 'index' + if self._type == TYPE_ALLERGY_FORECAST: + outlook = self.pollencom.data[TYPE_ALLERGY_OUTLOOK] - -class AllergyIndexSensor(BaseSensor): - """Define a sensor to show allergy index information.""" - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - try: - location_data = self.data.current_data['Location'] - [period] = [ - p for p in location_data['periods'] - if p['Type'] == self._data_params['key'] - ] + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_OUTLOOK: outlook['Outlook'], + ATTR_RATING: rating, + ATTR_SEASON: outlook['Season'].title(), + ATTR_STATE: data['State'], + ATTR_TREND: outlook['Trend'].title(), + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average + elif self._type == TYPE_ALLERGY_HISTORIC: + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: calculate_average_rating(indices), + ATTR_STATE: data['State'], + ATTR_TREND: trend, + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average + elif self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ALLERGY_YESTERDAY): + key = self._type.split('_')[-1].title() + [period] = [p for p in data['periods'] if p['Type'] == key] [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= period['Index'] <= i['maximum'] ] - for i in range(3): - index = i + 1 - try: - data = period['Triggers'][i] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_GENUS, index)] = data['Genus'] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_NAME, index)] = data['Name'] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_TYPE, index)] = data['PlantType'] - except IndexError: - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_GENUS, index)] = None - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_NAME, index)] = None - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_TYPE, index)] = None + for idx, attrs in enumerate(period['Triggers']): + index = idx + 1 + self._attrs.update({ + '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index): + attrs['Genus'], + '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): + attrs['Name'], + '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index): + attrs['PlantType'], + }) - self._attrs[ATTR_RATING] = rating - - except KeyError: - _LOGGER.error("Pollen.com API didn't return any data") - return - - try: - self._attrs[ATTR_CITY] = location_data['City'].title() - self._attrs[ATTR_STATE] = location_data['State'] - self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] - except KeyError: - _LOGGER.debug('Location data not included in API response') - self._attrs[ATTR_CITY] = None - self._attrs[ATTR_STATE] = None - self._attrs[ATTR_ZIP_CODE] = None - - try: - self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] - except KeyError: - _LOGGER.debug('Outlook data not included in API response') - self._attrs[ATTR_OUTLOOK] = None - - try: - self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] - except KeyError: - _LOGGER.debug('Season data not included in API response') - self._attrs[ATTR_SEASON] = None - - try: - self._attrs[ATTR_TREND] = self.data.outlook_data['Trend'].title() - except KeyError: - _LOGGER.debug('Trend data not included in API response') - self._attrs[ATTR_TREND] = None - - self._state = period['Index'] - self._unit = 'index' + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = period['Index'] + elif self._type == TYPE_DISEASE_FORECAST: + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_TREND: trend, + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average -class DataBase(object): - """Define a generic data object.""" +class PollenComData(object): + """Define a data object to retrieve info from Pollen.com.""" - def __init__(self, client): + def __init__(self, client, sensor_types): """Initialize.""" self._client = client + self._sensor_types = sensor_types + self.data = {} - def _get_client_data(self, module, operation): - """Get data from a particular point in the API.""" - from pypollencom.exceptions import HTTPError + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update Pollen.com data.""" + from pypollencom.errors import InvalidZipError, PollenComError + + # Pollen.com requires a bit more complicated error handling, given that + # it sometimes has parts (but not the whole thing) go down: + # + # 1. If `InvalidZipError` is thrown, quit everything immediately. + # 2. If an individual request throws any other error, try the others. - data = {} try: - data = getattr(getattr(self._client, module), operation)() - _LOGGER.debug('Received "%s_%s" data: %s', module, operation, data) - except HTTPError as exc: - _LOGGER.error('An error occurred while retrieving data') - _LOGGER.debug(exc) + if TYPE_ALLERGY_FORECAST in self._sensor_types: + try: + data = await self._client.allergens.extended() + self.data[TYPE_ALLERGY_FORECAST] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy forecast: %s', err) + self.data[TYPE_ALLERGY_FORECAST] = {} - return data + try: + data = await self._client.allergens.outlook() + self.data[TYPE_ALLERGY_OUTLOOK] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy outlook: %s', err) + self.data[TYPE_ALLERGY_OUTLOOK] = {} + if TYPE_ALLERGY_HISTORIC in self._sensor_types: + try: + data = await self._client.allergens.historic() + self.data[TYPE_ALLERGY_HISTORIC] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy history: %s', err) + self.data[TYPE_ALLERGY_HISTORIC] = {} -class AllergyAveragesData(DataBase): - """Define an object to averages on future and historical allergy data.""" + if all(s in self._sensor_types + for s in [TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ALLERGY_YESTERDAY]): + try: + data = await self._client.allergens.current() + self.data[TYPE_ALLERGY_INDEX] = data + except PollenComError as err: + _LOGGER.error('Unable to get current allergies: %s', err) + self.data[TYPE_ALLERGY_TODAY] = {} - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.extended_data = None - self.historic_data = None + if TYPE_DISEASE_FORECAST in self._sensor_types: + try: + data = await self._client.disease.extended() + self.data[TYPE_DISEASE_FORECAST] = data + except PollenComError as err: + _LOGGER.error('Unable to get disease forecast: %s', err) + self.data[TYPE_DISEASE_FORECAST] = {} - @Throttle(MIN_TIME_UPDATE_AVERAGES) - def update(self): - """Update with new data.""" - self.extended_data = self._get_client_data('allergens', 'extended') - self.historic_data = self._get_client_data('allergens', 'historic') - - -class AllergyIndexData(DataBase): - """Define an object to retrieve current allergy index info.""" - - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.current_data = None - self.outlook_data = None - - @Throttle(MIN_TIME_UPDATE_INDICES) - def update(self): - """Update with new index data.""" - self.current_data = self._get_client_data('allergens', 'current') - self.outlook_data = self._get_client_data('allergens', 'outlook') - - -class DiseaseData(DataBase): - """Define an object to retrieve current disease index info.""" - - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.extended_data = None - - @Throttle(MIN_TIME_UPDATE_INDICES) - def update(self): - """Update with new cold/flu data.""" - self.extended_data = self._get_client_data('disease', 'extended') + _LOGGER.debug('New data retrieved: %s', self.data) + except InvalidZipError: + _LOGGER.error( + 'Cannot retrieve data for ZIP code: %s', self._client.zip_code) + self.data = {} diff --git a/homeassistant/components/sensor/postnl.py b/homeassistant/components/sensor/postnl.py index 0e296fa56bd..9b35c1fdc7e 100644 --- a/homeassistant/components/sensor/postnl.py +++ b/homeassistant/components/sensor/postnl.py @@ -76,7 +76,7 @@ class PostNLSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return 'package(s)' + return 'packages' @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/pvoutput.py b/homeassistant/components/sensor/pvoutput.py index 26c3e27bba5..d4307d50228 100644 --- a/homeassistant/components/sensor/pvoutput.py +++ b/homeassistant/components/sensor/pvoutput.py @@ -64,7 +64,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([PvoutputSensor(rest, name)], True) -# pylint: disable=no-member class PvoutputSensor(Entity): """Representation of a PVOutput sensor.""" diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index 53cbaab19a5..2731587ed71 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -41,7 +41,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Skybeacon sensor.""" - # pylint: disable=unreachable name = config.get(CONF_NAME) mac = config.get(CONF_MAC) _LOGGER.debug("Setting up...") @@ -139,7 +138,7 @@ class Monitor(threading.Thread): def run(self): """Thread that keeps connection alive.""" - # pylint: disable=import-error, no-name-in-module, no-member + # pylint: disable=import-error import pygatt from pygatt.backends import Characteristic from pygatt.exceptions import ( diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 3451789424b..2be46da0bdb 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -198,5 +198,4 @@ class SMAsensor(Entity): update = True self._state = new_state - return self.async_update_ha_state() if update else None \ - # pylint: disable=protected-access + return self.async_update_ha_state() if update else None diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index e22e1594b55..7521b74cd28 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -76,7 +76,6 @@ class SteamSensor(Entity): """Return the state of the sensor.""" return self._state - # pylint: disable=no-member def update(self): """Update device state.""" try: diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py index ff8ad7fe849..737b3d08368 100644 --- a/homeassistant/components/sensor/tado.py +++ b/homeassistant/components/sensor/tado.py @@ -147,7 +147,6 @@ class TadoSensor(Entity): unit = TEMP_CELSIUS - # pylint: disable=R0912 if self.zone_variable == 'temperature': if 'sensorDataPoints' in data: sensor_data = data['sensorDataPoints'] diff --git a/homeassistant/components/sensor/ted5000.py b/homeassistant/components/sensor/ted5000.py index 55d520cf6ca..c2ef1d4c6b9 100644 --- a/homeassistant/components/sensor/ted5000.py +++ b/homeassistant/components/sensor/ted5000.py @@ -32,7 +32,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Ted5000 sensor.""" host = config.get(CONF_HOST) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index dbcfcb9cc27..fc40d17d0af 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -7,12 +7,14 @@ https://home-assistant.io/components/sensor.waze_travel_time/ from datetime import timedelta import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, + ATTR_LATITUDE, ATTR_LONGITUDE) import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.location as location from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -20,6 +22,7 @@ REQUIREMENTS = ['WazeRouteCalculator==0.5'] _LOGGER = logging.getLogger(__name__) +ATTR_DURATION = 'duration' ATTR_DISTANCE = 'distance' ATTR_ROUTE = 'route' @@ -46,6 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXCL_FILTER): cv.string, }) +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Waze travel time sensor platform.""" @@ -56,24 +61,46 @@ def setup_platform(hass, config, add_devices, discovery_info=None): incl_filter = config.get(CONF_INCL_FILTER) excl_filter = config.get(CONF_EXCL_FILTER) - try: - waze_data = WazeRouteData( - origin, destination, region, incl_filter, excl_filter) - except requests.exceptions.HTTPError as error: - _LOGGER.error("%s", error) - return + sensor = WazeTravelTime(name, origin, destination, region, + incl_filter, excl_filter) - add_devices([WazeTravelTime(waze_data, name)], True) + add_devices([sensor], True) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, sensor.update) + + +def _get_location_from_attributes(state): + """Get the lat/long string from an states attributes.""" + attr = state.attributes + return '{},{}'.format( + attr.get(ATTR_LATITUDE), + attr.get(ATTR_LONGITUDE) + ) class WazeTravelTime(Entity): """Representation of a Waze travel time sensor.""" - def __init__(self, waze_data, name): + def __init__(self, name, origin, destination, region, + incl_filter, excl_filter): """Initialize the Waze travel time sensor.""" self._name = name + self._region = region + self._incl_filter = incl_filter + self._excl_filter = excl_filter self._state = None - self.waze_data = waze_data + self._origin_entity_id = None + self._destination_entity_id = None + + if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._origin_entity_id = origin + else: + self._origin = origin + + if destination.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._destination_entity_id = destination + else: + self._destination = destination @property def name(self): @@ -83,7 +110,12 @@ class WazeTravelTime(Entity): @property def state(self): """Return the state of the sensor.""" - return round(self._state['duration']) + if self._state is None: + return None + + if 'duration' in self._state: + return round(self._state['duration']) + return None @property def unit_of_measurement(self): @@ -98,54 +130,97 @@ class WazeTravelTime(Entity): @property def device_state_attributes(self): """Return the state attributes of the last update.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_DISTANCE: round(self._state['distance']), - ATTR_ROUTE: self._state['route'], - } + if self._state is None: + return None - def update(self): - """Fetch new state data for the sensor.""" - try: - self.waze_data.update() - self._state = self.waze_data.data - except KeyError: - _LOGGER.error("Error retrieving data from server") + res = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} + if 'duration' in self._state: + res[ATTR_DURATION] = self._state['duration'] + if 'distance' in self._state: + res[ATTR_DISTANCE] = self._state['distance'] + if 'route' in self._state: + res[ATTR_ROUTE] = self._state['route'] + return res + def _get_location_from_entity(self, entity_id): + """Get the location from the entity_id.""" + state = self.hass.states.get(entity_id) -class WazeRouteData(object): - """Get data from Waze.""" + if state is None: + _LOGGER.error("Unable to find entity %s", entity_id) + return None - def __init__(self, origin, destination, region, incl_filter, excl_filter): - """Initialize the data object.""" - self._destination = destination - self._origin = origin - self._region = region - self._incl_filter = incl_filter - self._excl_filter = excl_filter - self.data = {} + # Check if the entity has location attributes (zone) + if location.has_location(state): + return _get_location_from_attributes(state) + + # Check if device is in a zone (device_tracker) + zone_state = self.hass.states.get('zone.{}'.format(state.state)) + if location.has_location(zone_state): + _LOGGER.debug( + "%s is in %s, getting zone location", + entity_id, zone_state.entity_id + ) + return _get_location_from_attributes(zone_state) + + # If zone was not found in state then use the state as the location + if entity_id.startswith('sensor.'): + return state.state + + # When everything fails just return nothing + return None + + def _resolve_zone(self, friendly_name): + """Get a lat/long from a zones friendly_name.""" + states = self.hass.states.all() + for state in states: + if state.domain == 'zone' and state.name == friendly_name: + return _get_location_from_attributes(state) + + return friendly_name @Throttle(SCAN_INTERVAL) def update(self): - """Fetch latest data from Waze.""" + """Fetch new state data for the sensor.""" import WazeRouteCalculator - _LOGGER.debug("Update in progress...") - try: - params = WazeRouteCalculator.WazeRouteCalculator( - self._origin, self._destination, self._region, None) - results = params.calc_all_routes_info() - if self._incl_filter is not None: - results = {k: v for k, v in results.items() if - self._incl_filter.lower() in k.lower()} - if self._excl_filter is not None: - results = {k: v for k, v in results.items() if - self._excl_filter.lower() not in k.lower()} - best_route = next(iter(results)) - (duration, distance) = results[best_route] - best_route_str = bytes(best_route, 'ISO-8859-1').decode('UTF-8') - self.data['duration'] = duration - self.data['distance'] = distance - self.data['route'] = best_route_str - except WazeRouteCalculator.WRCError as exp: - _LOGGER.error("Error on retrieving data: %s", exp) - return + + if self._origin_entity_id is not None: + self._origin = self._get_location_from_entity( + self._origin_entity_id + ) + + if self._destination_entity_id is not None: + self._destination = self._get_location_from_entity( + self._destination_entity_id + ) + + self._destination = self._resolve_zone(self._destination) + self._origin = self._resolve_zone(self._origin) + + if self._destination is not None and self._origin is not None: + try: + params = WazeRouteCalculator.WazeRouteCalculator( + self._origin, self._destination, self._region) + routes = params.calc_all_routes_info() + + if self._incl_filter is not None: + routes = {k: v for k, v in routes.items() if + self._incl_filter.lower() in k.lower()} + + if self._excl_filter is not None: + routes = {k: v for k, v in routes.items() if + self._excl_filter.lower() not in k.lower()} + + route = sorted(routes, key=(lambda key: routes[key][0]))[0] + duration, distance = routes[route] + route = bytes(route, 'ISO-8859-1').decode('UTF-8') + self._state = { + 'duration': duration, + 'distance': distance, + 'route': route} + except WazeRouteCalculator.WRCError as exp: + _LOGGER.error("Error on retrieving data: %s", exp) + return + except KeyError: + _LOGGER.error("Error retrieving data from server") + return diff --git a/homeassistant/components/sensor/wirelesstag.py b/homeassistant/components/sensor/wirelesstag.py index c93da3c791f..ad2115e9bd3 100755 --- a/homeassistant/components/sensor/wirelesstag.py +++ b/homeassistant/components/sensor/wirelesstag.py @@ -168,7 +168,7 @@ class WirelessTagSensor(WirelessTagBaseSensor): new_value = event.data.get('cap') elif self._sensor_type == SENSOR_LIGHT: new_value = event.data.get('lux') - except Exception as error: # pylint: disable=W0703 + except Exception as error: # pylint: disable=broad-except _LOGGER.info("Unable to update value of entity: \ %s error: %s event: %s", self, error, event) diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index a70d701fac6..63d93d31cf3 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -30,7 +30,11 @@ REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] ATTR_POWER = 'power' ATTR_CHARGING = 'charging' ATTR_BATTERY_LEVEL = 'battery_level' -ATTR_TIME_STATE = 'time_state' +ATTR_DISPLAY_CLOCK = 'display_clock' +ATTR_NIGHT_MODE = 'night_mode' +ATTR_NIGHT_TIME_BEGIN = 'night_time_begin' +ATTR_NIGHT_TIME_END = 'night_time_end' +ATTR_SENSOR_STATE = 'sensor_state' ATTR_MODEL = 'model' SUCCESS = ['ok'] @@ -85,7 +89,11 @@ class XiaomiAirQualityMonitor(Entity): ATTR_POWER: None, ATTR_BATTERY_LEVEL: None, ATTR_CHARGING: None, - ATTR_TIME_STATE: None, + ATTR_DISPLAY_CLOCK: None, + ATTR_NIGHT_MODE: None, + ATTR_NIGHT_TIME_BEGIN: None, + ATTR_NIGHT_TIME_END: None, + ATTR_SENSOR_STATE: None, ATTR_MODEL: self._model, } @@ -143,7 +151,11 @@ class XiaomiAirQualityMonitor(Entity): ATTR_POWER: state.power, ATTR_CHARGING: state.usb_power, ATTR_BATTERY_LEVEL: state.battery, - ATTR_TIME_STATE: state.time_state, + ATTR_DISPLAY_CLOCK: state.display_clock, + ATTR_NIGHT_MODE: state.night_mode, + ATTR_NIGHT_TIME_BEGIN: state.night_time_begin, + ATTR_NIGHT_TIME_END: state.night_time_end, + ATTR_SENSOR_STATE: state.sensor_state, }) except DeviceException as ex: diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 88c23771bd4..c7ff967723b 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -117,7 +117,7 @@ class YrSensor(Entity): return self._state @property - def should_poll(self): # pylint: disable=no-self-use + def should_poll(self): """No polling needed.""" return False diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index fe295d84d49..b2a913c2af8 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -5,8 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zwave/ """ import logging -# Because we do not compile openzwave on CI -# pylint: disable=import-error from homeassistant.components.sensor import DOMAIN from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT diff --git a/homeassistant/components/sonos/.translations/cs.json b/homeassistant/components/sonos/.translations/cs.json new file mode 100644 index 00000000000..c0b26284cdf --- /dev/null +++ b/homeassistant/components/sonos/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Sonos.", + "single_instance_allowed": "Je t\u0159eba jen jedna konfigurace Sonos." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/de.json b/homeassistant/components/sonos/.translations/de.json new file mode 100644 index 00000000000..f1b76b0d155 --- /dev/null +++ b/homeassistant/components/sonos/.translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Sonos konfigurieren?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json new file mode 100644 index 00000000000..4726d57ad24 --- /dev/null +++ b/homeassistant/components/sonos/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/it.json b/homeassistant/components/sonos/.translations/it.json new file mode 100644 index 00000000000..e32557f1d95 --- /dev/null +++ b/homeassistant/components/sonos/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Non sono presenti dispositivi Sonos in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Sonos." + }, + "step": { + "confirm": { + "description": "Vuoi installare Sonos", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/lb.json b/homeassistant/components/sonos/.translations/lb.json new file mode 100644 index 00000000000..26eaec4584d --- /dev/null +++ b/homeassistant/components/sonos/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Sonos Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Sonos ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Sonos konfigur\u00e9iert ginn?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/nl.json b/homeassistant/components/sonos/.translations/nl.json new file mode 100644 index 00000000000..de84482cc63 --- /dev/null +++ b/homeassistant/components/sonos/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Sonos-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Sonos nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Sonos instellen?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/sl.json b/homeassistant/components/sonos/.translations/sl.json new file mode 100644 index 00000000000..6773465bbbf --- /dev/null +++ b/homeassistant/components/sonos/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni najdenih naprav Sonos.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Sonosa." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/zh-Hant.json b/homeassistant/components/sonos/.translations/zh-Hant.json new file mode 100644 index 00000000000..c6fb13c3605 --- /dev/null +++ b/homeassistant/components/sonos/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Sonos \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Sonos \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Sonos\uff1f", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 9a35198628a..bab2abbad0d 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -135,7 +135,6 @@ async def async_setup(hass, config): class SwitchDevice(ToggleEntity): """Representation of a switch.""" - # pylint: disable=no-self-use @property def current_power_w(self): """Return the current power usage in W.""" diff --git a/homeassistant/components/switch/anel_pwrctrl.py b/homeassistant/components/switch/anel_pwrctrl.py index 9144222e5c7..4e62b711979 100644 --- a/homeassistant/components/switch/anel_pwrctrl.py +++ b/homeassistant/components/switch/anel_pwrctrl.py @@ -15,9 +15,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME) from homeassistant.util import Throttle -REQUIREMENTS = ['https://github.com/mweinelt/anel-pwrctrl/archive/' - 'ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip' - '#anel_pwrctrl==0.0.1'] +REQUIREMENTS = ['anel_pwrctrl-homeassistant==0.0.1.dev2'] _LOGGER = logging.getLogger(__name__) @@ -35,7 +33,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up PwrCtrl devices/switches.""" host = config.get(CONF_HOST, None) diff --git a/homeassistant/components/switch/digital_ocean.py b/homeassistant/components/switch/digital_ocean.py index 081eea80e2d..12a6aabb170 100644 --- a/homeassistant/components/switch/digital_ocean.py +++ b/homeassistant/components/switch/digital_ocean.py @@ -13,7 +13,8 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.components.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) + ATTR_REGION, ATTR_VCPUS, CONF_ATTRIBUTION, DATA_DIGITAL_OCEAN) +from homeassistant.const import ATTR_ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -69,6 +70,7 @@ class DigitalOceanSwitch(SwitchDevice): def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index f57843cdaa0..7df8f0e1aa6 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -218,7 +218,6 @@ class FluxSwitch(SwitchDevice): else: sunset_time = sunset - # pylint: disable=no-member night_length = int(stop_time.timestamp() - sunset_time.timestamp()) seconds_from_sunset = int(now.timestamp() - diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 58ad745a2d2..9968f631260 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): fritz = FritzBox(host, username, password) try: fritz.login() - except Exception: # pylint: disable=W0703 + except Exception: # pylint: disable=broad-except _LOGGER.error("Login to Fritz!Box failed") return diff --git a/homeassistant/components/switch/gc100.py b/homeassistant/components/switch/gc100.py index 54c3b5e942a..34a29483d3c 100644 --- a/homeassistant/components/switch/gc100.py +++ b/homeassistant/components/switch/gc100.py @@ -39,7 +39,6 @@ class GC100Switch(ToggleEntity): def __init__(self, name, port_addr, gc100): """Initialize the GC100 switch.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py index 6b97200ba49..3293c8fe195 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/switch/homekit_controller.py @@ -4,7 +4,6 @@ Support for Homekit switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.homekit_controller/ """ -import json import logging from homeassistant.components.homekit_controller import (HomeKitEntity, @@ -56,13 +55,11 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': True}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) def turn_off(self, **kwargs): """Turn the specified switch off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index 3d29c53bd7c..2a7dee87747 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -5,12 +5,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.switch import SwitchDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, ISYDevice) -from homeassistant.helpers.typing import ConfigType # noqa +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/mfi.py b/homeassistant/components/switch/mfi.py index c0dc72440d3..2c547fa210f 100644 --- a/homeassistant/components/switch/mfi.py +++ b/homeassistant/components/switch/mfi.py @@ -39,7 +39,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up mFi sensors.""" host = config.get(CONF_HOST) diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index a91ca6d11e7..340eed83b56 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -65,7 +65,7 @@ async def async_setup_platform( schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): +class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py index 1d149383f6f..34dad9bb581 100644 --- a/homeassistant/components/switch/neato.py +++ b/homeassistant/components/switch/neato.py @@ -10,12 +10,13 @@ import requests from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.neato import NEATO_ROBOTS, NEATO_LOGIN -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=10) + SWITCH_TYPE_SCHEDULE = 'schedule' SWITCH_TYPES = { @@ -52,7 +53,6 @@ class NeatoConnectedSwitch(ToggleEntity): self._schedule_state = None self._clean_state = None - @Throttle(timedelta(seconds=60)) def update(self): """Update the states of Neato switches.""" _LOGGER.debug("Running switch update") @@ -67,7 +67,7 @@ class NeatoConnectedSwitch(ToggleEntity): _LOGGER.debug('self._state=%s', self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) - if self.robot.schedule_enabled: + if self._state['details']['isScheduleEnabled']: self._schedule_state = STATE_ON else: self._schedule_state = STATE_OFF diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index dc661c3e5bf..5f0ca995c90 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -4,227 +4,239 @@ Integration with the Rachio Iro sprinkler system controller. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.rachio/ """ +from abc import abstractmethod from datetime import timedelta import logging - import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_ENABLED, + KEY_ID, + KEY_NAME, + KEY_ON, + KEY_SUBTYPE, + KEY_SUMMARY, + KEY_ZONE_ID, + KEY_ZONE_NUMBER, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + SIGNAL_RACHIO_ZONE_UPDATE, + SUBTYPE_ZONE_STARTED, + SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED, + SUBTYPE_SLEEP_MODE_ON, + SUBTYPE_SLEEP_MODE_OFF) import homeassistant.helpers.config_validation as cv -import homeassistant.util as util +from homeassistant.helpers.dispatcher import dispatcher_connect -REQUIREMENTS = ['rachiopy==0.1.2'] +DEPENDENCIES = ['rachio'] _LOGGER = logging.getLogger(__name__) +# Manual run length CONF_MANUAL_RUN_MINS = 'manual_run_mins' - -DATA_RACHIO = 'rachio' - DEFAULT_MANUAL_RUN_MINS = 10 -MIN_UPDATE_INTERVAL = timedelta(seconds=30) -MIN_FORCED_UPDATE_INTERVAL = timedelta(seconds=1) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS): cv.positive_int, }) +ATTR_ZONE_SUMMARY = 'Summary' +ATTR_ZONE_NUMBER = 'Zone number' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Rachio switches.""" - from rachiopy import Rachio + manual_run_time = timedelta(minutes=config.get(CONF_MANUAL_RUN_MINS)) + _LOGGER.info("Rachio run time is %s", str(manual_run_time)) - # Get options - manual_run_mins = config.get(CONF_MANUAL_RUN_MINS) - _LOGGER.debug("Rachio run time is %d min", manual_run_mins) + # Add all zones from all controllers as switches + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioStandbySwitch(hass, controller)) - access_token = config.get(CONF_ACCESS_TOKEN) + for zone in controller.list_zones(): + devices.append(RachioZone(hass, controller, zone, manual_run_time)) - # Configure API - _LOGGER.debug("Configuring Rachio API") - rachio = Rachio(access_token) - - person = None - try: - person = _get_person(rachio) - except KeyError: - _LOGGER.error( - "Could not reach the Rachio API. Is your access token valid?") - return - - # Get and persist devices - devices = _list_devices(rachio, manual_run_mins) - if not devices: - _LOGGER.error( - "No Rachio devices found in account %s", person['username']) - return - - hass.data[DATA_RACHIO] = devices[0] - - if len(devices) > 1: - _LOGGER.warning("Multiple Rachio devices found in account, " - "using %s", hass.data[DATA_RACHIO].device_id) - else: - _LOGGER.debug("Found Rachio device") - - hass.data[DATA_RACHIO].update() - add_devices(hass.data[DATA_RACHIO].list_zones()) + add_devices(devices) + _LOGGER.info("%d Rachio switch(es) added", len(devices)) -def _get_person(rachio): - """Pull the account info of the person whose access token was provided.""" - person_id = rachio.person.getInfo()[1]['id'] - return rachio.person.get(person_id)[1] +class RachioSwitch(SwitchDevice): + """Represent a Rachio state that can be toggled.""" + def __init__(self, controller, poll=True): + """Initialize a new Rachio switch.""" + self._controller = controller -def _list_devices(rachio, manual_run_mins): - """Pull a list of devices on the account.""" - return [RachioIro(rachio, d['id'], manual_run_mins) - for d in _get_person(rachio)['devices']] - - -class RachioIro(object): - """Representation of a Rachio Iro.""" - - def __init__(self, rachio, device_id, manual_run_mins): - """Initialize a Rachio device.""" - self.rachio = rachio - self._device_id = device_id - self.manual_run_mins = manual_run_mins - self._device = None - self._running = None - self._zones = None - - def __str__(self): - """Display the device as a string.""" - return "Rachio Iro {}".format(self.serial_number) + if poll: + self._state = self._poll_update() + else: + self._state = None @property - def device_id(self): - """Return the Rachio API device ID.""" - return self._device['id'] + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False @property - def status(self): - """Return the current status of the device.""" - return self._device['status'] + def name(self) -> str: + """Get a name for this switch.""" + return "Switch on {}".format(self._controller.name) @property - def serial_number(self): - """Return the serial number of the device.""" - return self._device['serialNumber'] + def is_on(self) -> bool: + """Return whether the switch is currently on.""" + return self._state + + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Poll the API.""" + pass + + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + + # For this device + self._handle_update(args, kwargs) + + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook data.""" + pass + + +class RachioStandbySwitch(RachioSwitch): + """Representation of a standby status/button.""" + + def __init__(self, hass, controller): + """Instantiate a new Rachio standby mode switch.""" + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + super().__init__(controller, poll=False) + self._poll_update(controller.init_data) @property - def is_paused(self): - """Return whether the device is temporarily disabled.""" - return self._device['paused'] + def name(self) -> str: + """Return the name of the standby switch.""" + return "{} in standby mode".format(self._controller.name) @property - def is_on(self): - """Return whether the device is powered on and connected.""" - return self._device['on'] + def icon(self) -> str: + """Return an icon for the standby switch.""" + return "mdi:power" - @property - def current_schedule(self): - """Return the schedule that the device is running right now.""" - return self._running + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] - def list_zones(self, include_disabled=False): - """Return a list of the zones connected to the device, incl. data.""" - if not self._zones: - self._zones = [RachioZone(self.rachio, self, zone['id'], - self.manual_run_mins) - for zone in self._device['zones']] + return not data[KEY_ON] - if include_disabled: - return self._zones + def _handle_update(self, *args, **kwargs) -> None: + """Update the state using webhook data.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: + self._state = False - self.update(no_throttle=True) - return [z for z in self._zones if z.is_enabled] + self.schedule_update_ha_state() - @util.Throttle(MIN_UPDATE_INTERVAL, MIN_FORCED_UPDATE_INTERVAL) - def update(self, **kwargs): - """Pull updated device info from the Rachio API.""" - self._device = self.rachio.device.get(self._device_id)[1] - self._running = self.rachio.device\ - .getCurrentSchedule(self._device_id)[1] + def turn_on(self, **kwargs) -> None: + """Put the controller in standby mode.""" + self._controller.rachio.device.off(self._controller.controller_id) - # Possibly update all zones - for zone in self.list_zones(include_disabled=True): - zone.update() - - _LOGGER.debug("Updated %s", str(self)) + def turn_off(self, **kwargs) -> None: + """Resume controller functionality.""" + self._controller.rachio.device.on(self._controller.controller_id) -class RachioZone(SwitchDevice): +class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" - def __init__(self, rachio, device, zone_id, manual_run_mins): + def __init__(self, hass, controller, data, manual_run_time): """Initialize a new Rachio Zone.""" - self.rachio = rachio - self._device = device - self._zone_id = zone_id - self._zone = None - self._manual_run_secs = manual_run_mins * 60 + self._id = data[KEY_ID] + self._zone_name = data[KEY_NAME] + self._zone_number = data[KEY_ZONE_NUMBER] + self._zone_enabled = data[KEY_ENABLED] + self._manual_run_time = manual_run_time + self._summary = str() + super().__init__(controller) + + # Listen for all zone updates + dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, + self._handle_update) def __str__(self): """Display the zone as a string.""" - return "Rachio Zone {}".format(self.name) + return 'Rachio Zone "{}" on {}'.format(self.name, + str(self._controller)) @property - def zone_id(self): + def zone_id(self) -> str: """How the Rachio API refers to the zone.""" - return self._zone['id'] + return self._id @property - def unique_id(self): - """Return the unique string ID for the zone.""" - return '{iro}-{zone}'.format( - iro=self._device.device_id, zone=self.zone_id) - - @property - def number(self): - """Return the physical connection of the zone pump.""" - return self._zone['zoneNumber'] - - @property - def name(self): + def name(self) -> str: """Return the friendly name of the zone.""" - return self._zone['name'] + return self._zone_name @property - def is_enabled(self): + def icon(self) -> str: + """Return the icon to display.""" + return "mdi:water" + + @property + def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" - return self._zone['enabled'] + return self._zone_enabled @property - def is_on(self): - """Return whether the zone is currently running.""" - schedule = self._device.current_schedule - return self.zone_id == schedule.get('zoneId') + def state_attributes(self) -> dict: + """Return the optional state attributes.""" + return { + ATTR_ZONE_NUMBER: self._zone_number, + ATTR_ZONE_SUMMARY: self._summary, + } - def update(self): - """Pull updated zone info from the Rachio API.""" - self._zone = self.rachio.zone.get(self._zone_id)[1] - - # Possibly update device - self._device.update() - - _LOGGER.debug("Updated %s", str(self)) - - def turn_on(self, **kwargs): - """Start the zone.""" + def turn_on(self, **kwargs) -> None: + """Start watering this zone.""" # Stop other zones first self.turn_off() - _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) - self.rachio.zone.start(self.zone_id, self._manual_run_secs) + # Start this zone + self._controller.rachio.zone.start(self.zone_id, + self._manual_run_time.seconds) + _LOGGER.debug("Watering %s on %s", self.name, self._controller.name) - def turn_off(self, **kwargs): - """Stop all zones.""" - _LOGGER.info("Stopping watering of all zones") - self.rachio.device.stopWater(self._device.device_id) + def turn_off(self, **kwargs) -> None: + """Stop watering all zones.""" + self._controller.stop_watering() + + def _poll_update(self, data=None) -> bool: + """Poll the API to check whether the zone is running.""" + schedule = self._controller.current_schedule + return self.zone_id == schedule.get(KEY_ZONE_ID) + + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook zone data.""" + if args[0][KEY_ZONE_ID] != self.zone_id: + return + + self._summary = kwargs.get(KEY_SUMMARY, str()) + + if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: + self._state = True + elif args[0][KEY_SUBTYPE] in [SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED]: + self._state = False + + self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 62c92ad2d96..03f11de21f7 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=import-error, no-member +# pylint: disable=no-member def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 46682d87356..eb54e7982a7 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_VOLTAGE) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyHS100==0.3.1'] +REQUIREMENTS = ['pyHS100==0.3.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 569566bcbfb..c18ad492d40 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -33,7 +33,6 @@ WEMO_OFF = 0 WEMO_STANDBY = 8 -# pylint: disable=too-many-function-args def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up discovered WeMo switches.""" import pywemo.discovery as discovery diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 3b82d87d7e7..8a0a1683aa4 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/switch.zwave/ """ import logging import time -# Because we do not compile openzwave on CI -# pylint: disable=import-error from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components import zwave from homeassistant.components.zwave import workaround, async_setup_platform # noqa # pylint: disable=unused-import diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index bd501167ffa..45fd8de2696 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -50,7 +50,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DemoVacuum(VacuumDevice): """Representation of a demo vacuum.""" - # pylint: disable=no-self-use def __init__(self, name, supported_features): """Initialize the vacuum.""" self._name = name diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index ef3bb0f636b..8c2f110257f 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -210,7 +210,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttVacuum(MqttAvailability, VacuumDevice): """Representation of a MQTT-controlled vacuum.""" - # pylint: disable=no-self-use def __init__( self, name, supported_features, qos, retain, command_topic, payload_turn_on, payload_turn_off, payload_return_to_base, diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 128bece8494..6289fed265d 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -15,12 +15,13 @@ from homeassistant.components.vacuum import ( SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON) from homeassistant.components.neato import ( NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=5) + SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ SUPPORT_STATUS | SUPPORT_MAP @@ -63,7 +64,6 @@ class NeatoConnectedVacuum(VacuumDevice): self.clean_suspension_charge_count = None self.clean_suspension_time = None - @Throttle(timedelta(seconds=60)) def update(self): """Update the states of Neato Vacuums.""" _LOGGER.debug("Running Neato Vacuums update") @@ -96,10 +96,14 @@ class NeatoConnectedVacuum(VacuumDevice): elif self._state['state'] == 4: self._status_state = ERRORS.get(self._state['error']) - if (self.robot.state['action'] == 1 or - self.robot.state['action'] == 2 or - self.robot.state['action'] == 3 and - self.robot.state['state'] == 2): + if (self._state['action'] == 1 or + self._state['action'] == 2 or + self._state['action'] == 3 and + self._state['state'] == 2): + self._clean_state = STATE_ON + elif (self._state['action'] == 11 or + self._state['action'] == 12 and + self._state['state'] == 2): self._clean_state = STATE_ON else: self._clean_state = STATE_OFF diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index cbbf279bb8c..0ab5e7ce39a 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -53,7 +53,6 @@ VERA_COMPONENTS = [ ] -# pylint: disable=too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" import pyvera as veraApi diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index c36c960c4fc..a43999f2276 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -46,7 +46,6 @@ def async_setup(hass, config): return True -# pylint: disable=no-member, no-self-use class WeatherEntity(Entity): """ABC for weather data.""" diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index f0712542ea5..7afa97fd4f6 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -12,7 +12,8 @@ from requests.exceptions import ( import voluptuous as vol from homeassistant.components.weather import ( - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_CONDITION, + PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -25,6 +26,22 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Dark Sky" +MAP_CONDITION = { + 'clear-day': 'sunny', + 'clear-night': 'clear-night', + 'rain': 'rainy', + 'snow': 'snowy', + 'sleet': 'snowy-rainy', + 'wind': 'windy', + 'fog': 'fog', + 'cloudy': 'cloudy', + 'partly-cloudy-day': 'partlycloudy', + 'partly-cloudy-night': 'partlycloudy', + 'hail': 'hail', + 'thunderstorm': 'lightning', + 'tornado': None, +} + CONF_UNITS = 'units' DEFAULT_NAME = 'Dark Sky' @@ -108,7 +125,7 @@ class DarkSkyWeather(WeatherEntity): @property def condition(self): """Return the weather condition.""" - return self._ds_currently.get('summary') + return MAP_CONDITION.get(self._ds_currently.get('icon')) @property def forecast(self): @@ -116,8 +133,11 @@ class DarkSkyWeather(WeatherEntity): return [{ ATTR_FORECAST_TIME: datetime.fromtimestamp(entry.d.get('time')).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get('temperature')} - for entry in self._ds_hourly.data] + ATTR_FORECAST_TEMP: + entry.d.get('temperature'), + ATTR_FORECAST_CONDITION: + MAP_CONDITION.get(entry.d.get('icon')) + } for entry in self._ds_hourly.data] def update(self): """Get the latest data from Dark Sky.""" diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index e16e5524f95..c26f68a2c29 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -228,7 +228,6 @@ class WebsocketAPIView(HomeAssistantView): async def get(self, request): """Handle an incoming websocket connection.""" - # pylint: disable=no-self-use return await ActiveConnection(request.app['hass'], request).handle() @@ -316,25 +315,32 @@ class ActiveConnection: authenticated = True else: + self.debug("Request auth") await self.wsock.send_json(auth_required_message()) msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) - if 'api_password' in msg: + if self.hass.auth.active and 'access_token' in msg: + self.debug("Received access_token") + token = self.hass.auth.async_get_access_token( + msg['access_token']) + authenticated = token is not None + + elif ((not self.hass.auth.active or + self.hass.auth.support_legacy) and + 'api_password' in msg): + self.debug("Received api_password") authenticated = validate_password( request, msg['api_password']) - elif 'access_token' in msg: - authenticated = \ - msg['access_token'] in self.hass.auth.access_tokens - if not authenticated: - self.debug("Invalid password") + self.debug("Authorization failed") await self.wsock.send_json( - auth_invalid_message('Invalid password')) + auth_invalid_message('Invalid access token or password')) await process_wrong_login(request) return wsock + self.debug("Auth OK") await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- @@ -392,7 +398,7 @@ class ActiveConnection: if wsock.closed: self.debug("Connection closed by client") else: - _LOGGER.exception("Unexpected TypeError: %s", msg) + _LOGGER.exception("Unexpected TypeError: %s", err) except ValueError as err: msg = "Received invalid JSON" @@ -403,7 +409,7 @@ class ActiveConnection: self._writer_task.cancel() except CANCELLATION_ERRORS: - self.debug("Connection cancelled by server") + self.debug("Connection cancelled") except asyncio.QueueFull: self.log_error("Client exceeded max pending messages [1]:", diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 15b75b2f7a8..e8c7db5efe1 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.25'] +REQUIREMENTS = ['pywemo==0.4.28'] DOMAIN = 'wemo' @@ -44,7 +44,6 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=too-many-function-args def setup(hass, config): """Set up for WeMo devices.""" import pywemo diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index e4dfc17246a..7c171d74967 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.8.0', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.9.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wirelesstag.py b/homeassistant/components/wirelesstag.py index 9fabcb1cd5a..0f8f47f5100 100644 --- a/homeassistant/components/wirelesstag.py +++ b/homeassistant/components/wirelesstag.py @@ -146,7 +146,7 @@ class WirelessTagPlatform: self.hass, SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type), event) - except Exception as ex: # pylint: disable=W0703 + except Exception as ex: # pylint: disable=broad-except _LOGGER.error("Unable to handle binary event:\ %s error: %s", str(event), str(ex)) diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py index 86531401774..471c1c6e82c 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -67,7 +67,6 @@ def setup(hass, config): return login() -# pylint: disable=no-member def login(): """Login to the ZoneMinder API.""" _LOGGER.debug("Attempting to login to ZoneMinder") @@ -118,13 +117,11 @@ def _zm_request(method, api_url, data=None): 'decode "%s"', req.text) -# pylint: disable=no-member def get_state(api_url): """Get a state from the ZoneMinder API service.""" return _zm_request('get', api_url) -# pylint: disable=no-member def change_state(api_url, post_data): """Update a state using the Zoneminder API.""" return _zm_request('post', api_url, data=post_data) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index a8ba5e4a6d3..e540259edd5 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -218,7 +218,6 @@ async def async_setup_platform(hass, config, async_add_devices, return True -# pylint: disable=R0914 async def async_setup(hass, config): """Set up Z-Wave. diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index 3e503e4d9a4..0228e64cf6e 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -345,7 +345,6 @@ DISC_SPECIFIC_DEVICE_CLASS = "specific_device_class" DISC_TYPE = "type" DISC_VALUES = "values" -# noqa # https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L49 # See also: # https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L275 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index db2912d7b42..be67ebd9cc3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -112,15 +112,13 @@ the flow from the config panel. """ import logging -import os import uuid -from . import data_entry_flow -from .core import callback -from .exceptions import HomeAssistantError -from .setup import async_setup_component, async_process_deps_reqs -from .util.json import load_json, save_json -from .util.decorator import Registry +from homeassistant import data_entry_flow +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component, async_process_deps_reqs +from homeassistant.util.decorator import Registry _LOGGER = logging.getLogger(__name__) @@ -136,6 +134,10 @@ FLOWS = [ ] +STORAGE_KEY = 'core.config_entries' +STORAGE_VERSION = 1 + +# Deprecated since 0.73 PATH_CONFIG = '.config_entries.json' SAVE_DELAY = 1 @@ -271,7 +273,7 @@ class ConfigEntries: hass, self._async_create_flow, self._async_finish_flow) self._hass_config = hass_config self._entries = None - self._sched_save = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @callback def async_domains(self): @@ -305,7 +307,7 @@ class ConfigEntries: raise UnknownEntry entry = self._entries.pop(found) - self._async_schedule_save() + await self._async_schedule_save() unloaded = await entry.async_unload(self.hass) @@ -314,14 +316,18 @@ class ConfigEntries: } async def async_load(self): - """Load the config.""" - path = self.hass.config.path(PATH_CONFIG) - if not os.path.isfile(path): + """Handle loading the config.""" + # Migrating for config entries stored before 0.73 + config = await self.hass.helpers.storage.async_migrator( + self.hass.config.path(PATH_CONFIG), self._store, + old_conf_migrate_func=_old_conf_migrator + ) + + if config is None: self._entries = [] return - entries = await self.hass.async_add_job(load_json, path) - self._entries = [ConfigEntry(**entry) for entry in entries] + self._entries = [ConfigEntry(**entry) for entry in config['entries']] async def async_forward_entry_setup(self, entry, component): """Forward the setup of an entry to a different component. @@ -372,7 +378,7 @@ class ConfigEntries: source=result['source'], ) self._entries.append(entry) - self._async_schedule_save() + await self._async_schedule_save() # Setup entry if entry.domain in self.hass.config.components: @@ -416,20 +422,14 @@ class ConfigEntries: return handler() - @callback - def _async_schedule_save(self): - """Schedule saving the entity registry.""" - if self._sched_save is not None: - self._sched_save.cancel() - - self._sched_save = self.hass.loop.call_later( - SAVE_DELAY, self.hass.async_add_job, self._async_save - ) - - async def _async_save(self): + async def _async_schedule_save(self): """Save the entity registry to a file.""" - self._sched_save = None - data = [entry.as_dict() for entry in self._entries] + data = { + 'entries': [entry.as_dict() for entry in self._entries] + } + await self._store.async_save(data, delay=SAVE_DELAY) - await self.hass.async_add_job( - save_json, self.hass.config.path(PATH_CONFIG), data) + +async def _old_conf_migrator(old_config): + """Migrate the pre-0.73 config format to the latest version.""" + return {'entries': old_config} diff --git a/homeassistant/const.py b/homeassistant/const.py index f1a4e55d662..57c1bccbd6a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 72 -PATCH_VERSION = '1' +MINOR_VERSION = 73 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -254,6 +254,7 @@ ATTR_DISCOVERED = 'discovered' # Location of the device/sensor ATTR_LOCATION = 'location' +ATTR_BATTERY_CHARGING = 'battery_charging' ATTR_BATTERY_LEVEL = 'battery_level' ATTR_WAKEUP = 'wake_up_interval' diff --git a/homeassistant/core.py b/homeassistant/core.py index 5e6dcd81310..e0950172913 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -230,6 +230,20 @@ class HomeAssistant(object): return task + @callback + def async_add_executor_job( + self, + target: Callable[..., Any], + *args: Any) -> asyncio.tasks.Task: + """Add an executor job from within the event loop.""" + task = self.loop.run_in_executor(None, target, *args) + + # If a task is scheduled + if self._track_task: + self._pending_tasks.append(task) + + return task + @callback def async_track_tasks(self): """Track tasks so you can wait for all tasks to be done.""" diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 91ec5051552..54cd569aceb 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -6,7 +6,7 @@ from typing import Any, Iterable, Tuple, Sequence, Dict from homeassistant.const import CONF_PLATFORM # Typing Imports and TypeAlias -# pylint: disable=using-constant-test,unused-import,wrong-import-order +# pylint: disable=using-constant-test,unused-import if False: from logging import Logger # NOQA @@ -14,7 +14,6 @@ if False: ConfigType = Dict[str, Any] -# pylint: disable=invalid-sequence-index def config_per_platform(config: ConfigType, domain: str) -> Iterable[Tuple[Any, Any]]: """Break a component config into different platforms. diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index bb34942ad79..5ee2cd56081 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -128,7 +128,6 @@ async def async_aiohttp_proxy_stream(hass, request, stream, content_type, @callback -# pylint: disable=invalid-name def _async_register_clientsession_shutdown(hass, clientsession): """Register ClientSession close on Home Assistant shutdown. diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index cb577e8a9c7..921b3bcf06b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -166,7 +166,8 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, try: value = float(value) except ValueError: - _LOGGER.warning("Value cannot be processed as a number: %s", value) + _LOGGER.warning("Value cannot be processed as a number: %s " + "(Offending entity: %s)", entity, value) return False if below is not None and value >= below: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 85050b5736f..7dc5d2524ec 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -59,7 +59,6 @@ def async_generate_entity_id(entity_id_format: str, name: Optional[str], class Entity(object): """An abstract class for Home Assistant entities.""" - # pylint: disable=no-self-use # SAFE TO OVERWRITE # The properties and methods here are safe to overwrite when inheriting # this class. These may be used to customize the behavior of the entity. @@ -365,7 +364,6 @@ class Entity(object): class ToggleEntity(Entity): """An abstract class for entities that can be turned on and off.""" - # pylint: disable=no-self-use @property def state(self) -> str: """Return the state.""" diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4a2cd5fa50c..04d9cc450ba 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -37,8 +37,6 @@ DISABLED_USER = 'user' class RegistryEntry: """Entity Registry Entry.""" - # pylint: disable=no-member - entity_id = attr.ib(type=str) unique_id = attr.ib(type=str) platform = attr.ib(type=str) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index d69a556b0cc..712b48da0d7 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -133,7 +133,6 @@ def async_track_same_state(hass, period, action, async_check_same_func, """Clear all unsub listener.""" nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - # pylint: disable=not-callable if async_remove_state_for_listener is not None: async_remove_state_for_listener() async_remove_state_for_listener = None diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py new file mode 100644 index 00000000000..962074ec3af --- /dev/null +++ b/homeassistant/helpers/storage.py @@ -0,0 +1,174 @@ +"""Helper to help store data.""" +import asyncio +import logging +import os +from typing import Dict, Optional + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.util import json +from homeassistant.helpers.event import async_call_later + +STORAGE_DIR = '.storage' +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_migrator(hass, old_path, store, *, old_conf_migrate_func=None): + """Helper function to migrate old data to a store and then load data. + + async def old_conf_migrate_func(old_data) + """ + def load_old_config(): + """Helper to load old config.""" + if not os.path.isfile(old_path): + return None + + return json.load_json(old_path) + + config = await hass.async_add_executor_job(load_old_config) + + if config is None: + return await store.async_load() + + if old_conf_migrate_func is not None: + config = await old_conf_migrate_func(config) + + await store.async_save(config) + await hass.async_add_executor_job(os.remove, old_path) + return config + + +@bind_hass +class Store: + """Class to help storing data.""" + + def __init__(self, hass, version: int, key: str): + """Initialize storage class.""" + self.version = version + self.key = key + self.hass = hass + self._data = None + self._unsub_delay_listener = None + self._unsub_stop_listener = None + self._write_lock = asyncio.Lock() + self._load_task = None + + @property + def path(self): + """Return the config path.""" + return self.hass.config.path(STORAGE_DIR, self.key) + + async def async_load(self): + """Load data. + + If the expected version does not match the given version, the migrate + function will be invoked with await migrate_func(version, config). + + Will ensure that when a call comes in while another one is in progress, + the second call will wait and return the result of the first call. + """ + if self._load_task is None: + self._load_task = self.hass.async_add_job(self._async_load()) + + return await self._load_task + + async def _async_load(self): + """Helper to load the data.""" + if self._data is not None: + data = self._data + else: + data = await self.hass.async_add_executor_job( + json.load_json, self.path, None) + + if data is None: + return None + + if data['version'] == self.version: + stored = data['data'] + else: + _LOGGER.info('Migrating %s storage from %s to %s', + self.key, data['version'], self.version) + stored = await self._async_migrate_func( + data['version'], data['data']) + + self._load_task = None + return stored + + async def async_save(self, data: Dict, *, delay: Optional[int] = None): + """Save data with an optional delay.""" + self._data = { + 'version': self.version, + 'key': self.key, + 'data': data, + } + + self._async_cleanup_delay_listener() + + if delay is None: + self._async_cleanup_stop_listener() + await self._async_handle_write_data() + return + + self._unsub_delay_listener = async_call_later( + self.hass, delay, self._async_callback_delayed_write) + + self._async_ensure_stop_listener() + + @callback + def _async_ensure_stop_listener(self): + """Ensure that we write if we quit before delay has passed.""" + if self._unsub_stop_listener is None: + self._unsub_stop_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write) + + @callback + def _async_cleanup_stop_listener(self): + """Clean up a stop listener.""" + if self._unsub_stop_listener is not None: + self._unsub_stop_listener() + self._unsub_stop_listener = None + + @callback + def _async_cleanup_delay_listener(self): + """Clean up a delay listener.""" + if self._unsub_delay_listener is not None: + self._unsub_delay_listener() + self._unsub_delay_listener = None + + async def _async_callback_delayed_write(self, _now): + """Handle a delayed write callback.""" + self._unsub_delay_listener = None + self._async_cleanup_stop_listener() + await self._async_handle_write_data() + + async def _async_callback_stop_write(self, _event): + """Handle a write because Home Assistant is stopping.""" + self._unsub_stop_listener = None + self._async_cleanup_delay_listener() + await self._async_handle_write_data() + + async def _async_handle_write_data(self, *_args): + """Handler to handle writing the config.""" + data = self._data + self._data = None + + async with self._write_lock: + try: + await self.hass.async_add_executor_job( + self._write_data, self.path, data) + except (json.SerializationError, json.WriteError) as err: + _LOGGER.error('Error writing config for %s: %s', self.key, err) + + def _write_data(self, path: str, data: Dict): + """Write the data.""" + if not os.path.isdir(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + + _LOGGER.debug('Writing data for %s', self.key) + json.save_json(path, data) + + async def _async_migrate_func(self, old_version, old_data): + """Migrate to the new version.""" + raise NotImplementedError diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ce93c8705b5..e3e41e09db2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -81,7 +81,7 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: potential_paths = ['custom_components.{}'.format(comp_or_platform), 'homeassistant.components.{}'.format(comp_or_platform)] - for path in potential_paths: + for index, path in enumerate(potential_paths): try: module = importlib.import_module(path) @@ -100,6 +100,14 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: cache[comp_or_platform] = module + if index == 0: + _LOGGER.warning( + 'You are using a custom component for %s which has not ' + 'been tested by Home Assistant. This component might ' + 'cause stability problems, be sure to disable it if you ' + 'do experience issues with Home Assistant.', + comp_or_platform) + return module except ImportError as err: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5e7386242ba..32374b90135 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,8 +7,7 @@ jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.11,<4 -requests==2.18.4 -typing>=3,<4 +requests==2.19.1 voluptuous==0.11.1 # Breaks Python 3.6 and is not needed for our supported Python versions diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 5a33bd58641..b3e5f417618 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -31,7 +31,6 @@ _LOGGER = logging.getLogger(__name__) class APIStatus(enum.Enum): """Representation of an API status.""" - # pylint: disable=no-init, invalid-name OK = "ok" INVALID_PASSWORD = "invalid_password" CANNOT_CONNECT = "cannot_connect" diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index b4f1ddd2f11..dacdc7b18e2 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -1,7 +1,9 @@ """Script to manage users for the Home Assistant auth provider.""" import argparse +import asyncio import os +from homeassistant.core import HomeAssistant from homeassistant.config import get_default_config_dir from homeassistant.auth_providers import homeassistant as hass_auth @@ -17,7 +19,8 @@ def run(args): default=get_default_config_dir(), help="Directory that contains the Home Assistant configuration") - subparsers = parser.add_subparsers() + subparsers = parser.add_subparsers(dest='func') + subparsers.required = True parser_list = subparsers.add_parser('list') parser_list.set_defaults(func=list_users) @@ -37,11 +40,15 @@ def run(args): parser_change_pw.set_defaults(func=change_password) args = parser.parse_args(args) - path = os.path.join(os.getcwd(), args.config, hass_auth.PATH_DATA) - args.func(hass_auth.load_data(path), args) + loop = asyncio.get_event_loop() + hass = HomeAssistant(loop=loop) + hass.config.config_dir = os.path.join(os.getcwd(), args.config) + data = hass_auth.Data(hass) + loop.run_until_complete(data.async_load()) + loop.run_until_complete(args.func(data, args)) -def list_users(data, args): +async def list_users(data, args): """List the users.""" count = 0 for user in data.users: @@ -52,14 +59,14 @@ def list_users(data, args): print("Total users:", count) -def add_user(data, args): +async def add_user(data, args): """Create a user.""" data.add_user(args.username, args.password) - data.save() + await data.async_save() print("User created") -def validate_login(data, args): +async def validate_login(data, args): """Validate a login.""" try: data.validate_login(args.username, args.password) @@ -68,11 +75,11 @@ def validate_login(data, args): print("Auth invalid") -def change_password(data, args): +async def change_password(data, args): """Change password.""" try: data.change_password(args.username, args.new_password) - data.save() + await data.async_save() print("Password changed") except hass_auth.InvalidUser: print("User not found") diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 3a1ffa82d47..69b1bf21c08 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -267,7 +267,7 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): print(' ', indent_str, i) -CheckConfigError = namedtuple( # pylint: disable=invalid-name +CheckConfigError = namedtuple( 'CheckConfigError', "message domain config") @@ -378,7 +378,6 @@ def check_ha_config_file(hass): # Validate platform specific schema if hasattr(platform, 'PLATFORM_SCHEMA'): - # pylint: disable=no-member try: p_validated = platform.PLATFORM_SCHEMA(p_validated) except vol.Invalid as ex: diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index e02305b5fbb..51d70d1f3b2 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==12.2.1', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==13.0.0', 'keyrings.alt==3.1'] def run(args): diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index a8a84c6c880..bbf0f7e11e2 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -120,7 +120,6 @@ def get_random_string(length=10): class OrderedEnum(enum.Enum): """Taken from Python 3.4.0 docs.""" - # pylint: disable=no-init def __ge__(self, other): """Return the greater than element.""" if self.__class__ is other.__class__: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 5676a1d0844..b3aa370da2e 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -107,7 +107,6 @@ def run_coroutine_threadsafe(coro, loop): def callback(): """Handle the call to the coroutine.""" try: - # pylint: disable=deprecated-method _chain_future(ensure_future(coro, loop=loop), future) # pylint: disable=broad-except except Exception as exc: @@ -136,7 +135,6 @@ def fire_coroutine_threadsafe(coro, loop): def callback(): """Handle the firing of a coroutine.""" - # pylint: disable=deprecated-method ensure_future(coro, loop=loop) loop.call_soon_threadsafe(callback) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 32e9df70a03..d2138f4293c 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -173,7 +173,7 @@ def color_name_to_rgb(color_name): return hex_value -# pylint: disable=invalid-name, invalid-sequence-index +# pylint: disable=invalid-name def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: """Convert from RGB color to XY color.""" return color_RGB_to_xy_brightness(iR, iG, iB)[:2] @@ -182,7 +182,7 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: # Taken from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # License: Code is given as is. Use at your own risk and discretion. -# pylint: disable=invalid-name, invalid-sequence-index +# pylint: disable=invalid-name def color_RGB_to_xy_brightness( iR: int, iG: int, iB: int) -> Tuple[float, float, int]: """Convert from RGB color to XY color.""" @@ -224,7 +224,6 @@ def color_xy_to_RGB(vX: float, vY: float) -> Tuple[int, int, int]: # Converted to Python from Obj-C, original source from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy -# pylint: disable=invalid-sequence-index def color_xy_brightness_to_RGB(vX: float, vY: float, ibrightness: int) -> Tuple[int, int, int]: """Convert from XYZ to RGB.""" @@ -265,7 +264,6 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, return (ir, ig, ib) -# pylint: disable=invalid-sequence-index def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: """Convert a hsb into its rgb representation.""" if fS == 0: @@ -307,7 +305,6 @@ def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: return (r, g, b) -# pylint: disable=invalid-sequence-index def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: """Convert an rgb color to its hsv representation. @@ -319,13 +316,11 @@ def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3) -# pylint: disable=invalid-sequence-index def color_RGB_to_hs(iR: int, iG: int, iB: int) -> Tuple[float, float]: """Convert an rgb color to its hs representation.""" return color_RGB_to_hsv(iR, iG, iB)[:2] -# pylint: disable=invalid-sequence-index def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: """Convert an hsv color into its rgb representation. @@ -337,26 +332,22 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: return (int(fRGB[0]*255), int(fRGB[1]*255), int(fRGB[2]*255)) -# pylint: disable=invalid-sequence-index def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]: """Convert an hsv color into its rgb representation.""" return color_hsv_to_RGB(iH, iS, 100) -# pylint: disable=invalid-sequence-index def color_xy_to_hs(vX: float, vY: float) -> Tuple[float, float]: """Convert an xy color to its hs representation.""" h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY)) return (h, s) -# pylint: disable=invalid-sequence-index def color_hs_to_xy(iH: float, iS: float) -> Tuple[float, float]: """Convert an hs color to its xy representation.""" return color_RGB_to_xy(*color_hs_to_RGB(iH, iS)) -# pylint: disable=invalid-sequence-index def _match_max_scale(input_colors: Tuple[int, ...], output_colors: Tuple[int, ...]) -> Tuple[int, ...]: """Match the maximum value of the output to the input.""" diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index cd440783cc3..37b917baa2e 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -184,7 +184,6 @@ def get_age(date: dt.datetime) -> str: elif number > 1: return "%d %ss" % (number, unit) - # pylint: disable=invalid-sequence-index def q_n_r(first: int, second: int) -> Tuple[int, int]: """Return quotient and remaining.""" return first // second, first % second diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index b2577ff6be6..0e53342b0ca 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -11,6 +11,14 @@ _LOGGER = logging.getLogger(__name__) _UNDEFINED = object() +class SerializationError(HomeAssistantError): + """Error serializing the data to JSON.""" + + +class WriteError(HomeAssistantError): + """Error writing the data.""" + + def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. @@ -41,13 +49,11 @@ def save_json(filename: str, data: Union[List, Dict]): data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: fdesc.write(data) - return True except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', filename) - raise HomeAssistantError(error) + raise SerializationError(error) except OSError as error: _LOGGER.exception('Saving JSON file failed: %s', filename) - raise HomeAssistantError(error) - return False + raise WriteError(error) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index dae8ed17dc9..e390b537d34 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -82,7 +82,7 @@ def elevation(latitude, longitude): # Author: https://github.com/maurycyp # Source: https://github.com/maurycyp/vincenty # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE -# pylint: disable=invalid-name, unused-variable, invalid-sequence-index +# pylint: disable=invalid-name def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], miles: bool = False) -> Optional[float]: """ diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 66d673987a3..0e7befd5e9e 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -13,7 +13,7 @@ except ImportError: keyring = None try: - import credstash # pylint: disable=import-error, no-member + import credstash except ImportError: credstash = None @@ -246,7 +246,6 @@ def _load_secret_yaml(secret_path: str) -> Dict: return secrets -# pylint: disable=protected-access def _secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node): """Load secrets and embed it into the configuration YAML.""" diff --git a/pylintrc b/pylintrc index df839b379b5..d47437cb121 100644 --- a/pylintrc +++ b/pylintrc @@ -1,6 +1,4 @@ -[MASTER] -reports=no - +[MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable @@ -14,9 +12,6 @@ reports=no # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise - -generated-members=botocore.errorfactory - disable= abstract-class-little-used, abstract-class-not-used, @@ -39,9 +34,13 @@ disable= too-many-statements, unused-argument +[REPORTS] +reports=no + +[TYPECHECK] +# For attrs +ignored-classes=_CountingAttr +generated-members=botocore.errorfactory + [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError - -# For attrs -[typecheck] -ignored-classes=_CountingAttr diff --git a/requirements_all.txt b/requirements_all.txt index cd9dfb5194a..c93c417b111 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,12 +8,11 @@ jinja2>=2.10 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.11,<4 -requests==2.18.4 -typing>=3,<4 +requests==2.19.1 voluptuous==0.11.1 # homeassistant.components.nuimo_controller ---only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 +--only-binary=all nuimo==0.1.0 # homeassistant.components.sensor.sht31 Adafruit-GPIO==1.0.3 @@ -24,6 +23,9 @@ Adafruit-SHT31==1.0.2 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 +# homeassistant.components.sensor.dht +# Adafruit_Python_DHT==1.3.2 + # homeassistant.components.doorbird DoorBirdPy==0.1.3 @@ -82,7 +84,7 @@ aioautomatic==0.6.5 aiodns==1.1.1 # homeassistant.components.device_tracker.freebox -aiofreepybox==0.0.3 +aiofreepybox==0.0.4 # homeassistant.components.camera.yi aioftp==0.10.1 @@ -115,6 +117,9 @@ alpha_vantage==2.0.0 # homeassistant.components.amcrest amcrest==1.2.3 +# homeassistant.components.switch.anel_pwrctrl +anel_pwrctrl-homeassistant==0.0.1.dev2 + # homeassistant.components.media_player.anthemav anthemav==1.1.8 @@ -184,6 +189,9 @@ boto3==1.4.7 # homeassistant.scripts.credstash botocore==1.7.34 +# homeassistant.components.media_player.braviatv +braviarc-homeassistant==0.3.7.dev0 + # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink broadlink==0.9.0 @@ -301,7 +309,7 @@ ephem==3.7.6.0 epson-projector==0.1.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.1 +eternalegypt==0.0.2 # homeassistant.components.keyboard_remote # evdev==0.6.1 @@ -400,11 +408,14 @@ hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 +# homeassistant.components.sensor.pi_hole +hole==0.3.0 + # homeassistant.components.binary_sensor.workday holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180625.0 +home-assistant-frontend==20180704.0 # homeassistant.components.homekit_controller # homekit==0.6 @@ -418,24 +429,9 @@ http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b89974819 # homeassistant.components.remember_the_milk httplib2==0.10.3 -# homeassistant.components.sensor.dht -# https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 - -# homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 - -# homeassistant.components.neato -https://github.com/jabesq/pybotvac/archive/v0.0.6.zip#pybotvac==0.0.6 - -# homeassistant.components.switch.anel_pwrctrl -https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 - # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 -# homeassistant.components.binary_sensor.flic -https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 - # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 @@ -464,7 +460,7 @@ influxdb==5.0.0 insteonlocal==0.53 # homeassistant.components.insteon_plm -insteonplm==0.10.0 +insteonplm==0.11.3 # homeassistant.components.sensor.iperf3 iperf3==0.1.10 @@ -480,7 +476,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==12.2.1 +keyring==13.0.0 # homeassistant.scripts.keyring keyrings.alt==3.1 @@ -505,7 +501,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==1.0.5 +librouteros==2.1.0 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 @@ -586,7 +582,7 @@ nanoleaf==0.4.1 netdata==0.1.2 # homeassistant.components.discovery -netdisco==1.4.1 +netdisco==1.5.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -602,7 +598,7 @@ nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.3 +numpy==1.14.5 # homeassistant.components.google oauth2client==4.0.0 @@ -651,9 +647,6 @@ pifacedigitalio==3.0.5 # homeassistant.components.light.piglow piglow==1.2.4 -# homeassistant.components.sensor.pi_hole -pihole==0.1.2 - # homeassistant.components.pilight pilight==0.1.1 @@ -681,7 +674,7 @@ postnl_api==1.0.2 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.1.0 +prometheus_client==0.2.0 # homeassistant.components.sensor.systemmonitor psutil==5.4.6 @@ -719,7 +712,7 @@ pyCEC==0.4.13 # homeassistant.components.light.tplink # homeassistant.components.switch.tplink -pyHS100==0.3.1 +pyHS100==0.3.2 # homeassistant.components.rfxtrx pyRFXtrx==0.22.1 @@ -743,7 +736,7 @@ pyairvisual==2.0.1 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.1.7 +pyarlo==0.1.8 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 @@ -767,6 +760,9 @@ pyblackbird==0.5 # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 +# homeassistant.components.neato +pybotvac==0.0.7 + # homeassistant.components.media_player.channels pychannels==1.0.0 @@ -790,7 +786,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==38 +pydeconz==39 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -825,6 +821,9 @@ pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.binary_sensor.flic +pyflic-homeassistant==0.4.dev0 + # homeassistant.components.fritzbox pyfritzhome==0.3.7 @@ -847,7 +846,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.43 +pyhomematic==0.1.44 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -955,7 +954,7 @@ pyotp==2.2.6 pyowm==2.8.0 # homeassistant.components.sensor.pollen -pypollencom==1.1.2 +pypollencom==2.1.0 # homeassistant.components.qwikswitch pyqwikswitch==0.8 @@ -1099,7 +1098,7 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.8.0 +python-wink==1.9.1 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.3 @@ -1153,7 +1152,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.25 +pywemo==0.4.28 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 @@ -1164,8 +1163,8 @@ pyzabbix==0.7.4 # homeassistant.components.sensor.qnap qnapstats==0.2.6 -# homeassistant.components.switch.rachio -rachiopy==0.1.2 +# homeassistant.components.rachio +rachiopy==0.1.3 # homeassistant.components.climate.radiotherm radiotherm==1.3 @@ -1219,7 +1218,7 @@ schiene==0.22 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.4.0 +sendgrid==5.4.1 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat @@ -1433,7 +1432,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.06.02 +youtube_dl==2018.06.25 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f7967761de..f0c64b63147 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.2 holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180625.0 +home-assistant-frontend==20180704.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -99,7 +99,7 @@ mficlient==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.3 +numpy==1.14.5 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -120,7 +120,7 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.1.0 +prometheus_client==0.2.0 # homeassistant.components.notify.pushbullet # homeassistant.components.sensor.pushbullet @@ -133,7 +133,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==38 +pydeconz==39 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/script/lazytox.py b/script/lazytox.py index 19af5560dfb..f0388a0fdcb 100755 --- a/script/lazytox.py +++ b/script/lazytox.py @@ -39,7 +39,6 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable=E0402 from gen_requirements_all import main as req_main return req_main(True) == 0 @@ -70,7 +69,6 @@ async def async_exec(*args, display=False): 'stderr': asyncio.subprocess.STDOUT} if display: kwargs['stderr'] = asyncio.subprocess.PIPE - # pylint: disable=E1120 proc = await asyncio.create_subprocess_exec(*args, **kwargs) except FileNotFoundError as err: printc(FAIL, "Could not execute {}. Did you install test requirements?" diff --git a/script/monkeytype b/script/monkeytype new file mode 100755 index 00000000000..dc1894c91ed --- /dev/null +++ b/script/monkeytype @@ -0,0 +1,25 @@ +#!/bin/sh +# Run monkeytype on test suite or optionally on a test module or directory. + +# Stop on errors +set -e + +cd "$(dirname "$0")/.." + +command -v pytest >/dev/null 2>&1 || { + echo >&2 "This script requires pytest but it's not installed." \ + "Aborting. Try: pip install pytest"; exit 1; } + +command -v monkeytype >/dev/null 2>&1 || { + echo >&2 "This script requires monkeytype but it's not installed." \ + "Aborting. Try: pip install monkeytype"; exit 1; } + +if [ $# -eq 0 ] + then + echo "Run monkeytype on test suite" + monkeytype run "`command -v pytest`" + exit +fi + +echo "Run monkeytype on tests in $1" +monkeytype run "`command -v pytest`" "$1" diff --git a/script/translations_download b/script/translations_download index 099e32c9d1b..15b6a681056 100755 --- a/script/translations_download +++ b/script/translations_download @@ -28,7 +28,7 @@ mkdir -p ${LOCAL_DIR} docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:ddf5677f58551261008342df5849731c88bcdc152ab645b133b21819aede8218 lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ --export_empty skip \ diff --git a/setup.cfg b/setup.cfg index 8b17da455dc..7813cc5c047 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,20 @@ +[metadata] +license = Apache License 2.0 +license_file = LICENSE.md +platforms = any +description = Open-source home automation platform running on Python 3. +long_description = file: README.rst +keywords = home, automation +classifier = + Development Status :: 4 - Beta + Intended Audience :: End Users/Desktop + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Topic :: Home Automation + [tool:pytest] testpaths = tests norecursedirs = .git testing_config diff --git a/setup.py b/setup.py index f914e032fd7..928d894c9d1 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" +from datetime import datetime as dt from setuptools import setup, find_packages import homeassistant.const as hass_const @@ -8,26 +9,9 @@ PROJECT_NAME = 'Home Assistant' PROJECT_PACKAGE_NAME = 'homeassistant' PROJECT_LICENSE = 'Apache License 2.0' PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_COPYRIGHT = ' 2013-{}, {}'.format(dt.now().year, PROJECT_AUTHOR) PROJECT_URL = 'https://home-assistant.io/' PROJECT_EMAIL = 'hello@home-assistant.io' -PROJECT_DESCRIPTION = ('Open-source home automation platform ' - 'running on Python 3.') -PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' - 'home automation platform running on Python 3. ' - 'Track and control all devices at home and ' - 'automate control. ' - 'Installation in less than a minute.') -PROJECT_CLASSIFIERS = [ - 'Development Status :: 4 - Beta', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Home Automation' -] PROJECT_GITHUB_USERNAME = 'home-assistant' PROJECT_GITHUB_REPOSITORY = 'home-assistant' @@ -38,6 +22,12 @@ GITHUB_PATH = '{}/{}'.format( GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) +PROJECT_URLS = { + 'Bug Reports': '{}/issues'.format(GITHUB_URL), + 'Dev Docs': 'https://developers.home-assistant.io/', + 'Discord': 'https://discordapp.com/invite/c5DvZ4e', + 'Forum': 'https://community.home-assistant.io/', +} PACKAGES = find_packages(exclude=['tests', 'tests.*']) @@ -51,8 +41,7 @@ REQUIRES = [ 'pip>=8.0.3', 'pytz>=2018.04', 'pyyaml>=3.11,<4', - 'requests==2.18.4', - 'typing>=3,<4', + 'requests==2.19.1', 'voluptuous==0.11.1', ] @@ -61,24 +50,20 @@ MIN_PY_VERSION = '.'.join(map(str, hass_const.REQUIRED_PYTHON_VER)) setup( name=PROJECT_PACKAGE_NAME, version=hass_const.__version__, - license=PROJECT_LICENSE, url=PROJECT_URL, download_url=DOWNLOAD_URL, + project_urls=PROJECT_URLS, author=PROJECT_AUTHOR, author_email=PROJECT_EMAIL, - description=PROJECT_DESCRIPTION, packages=PACKAGES, include_package_data=True, zip_safe=False, - platforms='any', install_requires=REQUIRES, python_requires='>={}'.format(MIN_PY_VERSION), test_suite='tests', - keywords=['home', 'automation'], entry_points={ 'console_scripts': [ 'hass = homeassistant.__main__:main' ] }, - classifiers=PROJECT_CLASSIFIERS, ) diff --git a/tests/auth_providers/test_homeassistant.py b/tests/auth_providers/test_homeassistant.py index 8b12e682865..1d9a29bf48b 100644 --- a/tests/auth_providers/test_homeassistant.py +++ b/tests/auth_providers/test_homeassistant.py @@ -1,60 +1,48 @@ """Test the Home Assistant local auth provider.""" -from unittest.mock import patch, mock_open - import pytest from homeassistant import data_entry_flow from homeassistant.auth_providers import homeassistant as hass_auth -MOCK_PATH = '/bla/users.json' -JSON__OPEN_PATH = 'homeassistant.util.json.open' +@pytest.fixture +def data(hass): + """Create a loaded data class.""" + data = hass_auth.Data(hass) + hass.loop.run_until_complete(data.async_load()) + return data -def test_initialize_empty_config_file_not_found(): - """Test that we initialize an empty config.""" - with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): - data = hass_auth.load_data(MOCK_PATH) - - assert data is not None - - -def test_adding_user(): +async def test_adding_user(data, hass): """Test adding a user.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') data.validate_login('test-user', 'test-pass') -def test_adding_user_duplicate_username(): +async def test_adding_user_duplicate_username(data, hass): """Test adding a user.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidUser): data.add_user('test-user', 'other-pass') -def test_validating_password_invalid_user(): +async def test_validating_password_invalid_user(data, hass): """Test validating an invalid user.""" - data = hass_auth.Data(MOCK_PATH, None) - with pytest.raises(hass_auth.InvalidAuth): data.validate_login('non-existing', 'pw') -def test_validating_password_invalid_password(): +async def test_validating_password_invalid_password(data, hass): """Test validating an invalid user.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidAuth): data.validate_login('test-user', 'invalid-pass') -def test_changing_password(): +async def test_changing_password(data, hass): """Test adding a user.""" user = 'test-user' - data = hass_auth.Data(MOCK_PATH, None) data.add_user(user, 'test-pass') data.change_password(user, 'new-pass') @@ -64,61 +52,50 @@ def test_changing_password(): data.validate_login(user, 'new-pass') -def test_changing_password_raises_invalid_user(): +async def test_changing_password_raises_invalid_user(data, hass): """Test that we initialize an empty config.""" - data = hass_auth.Data(MOCK_PATH, None) - with pytest.raises(hass_auth.InvalidUser): data.change_password('non-existing', 'pw') -async def test_login_flow_validates(hass): +async def test_login_flow_validates(data, hass): """Test login flow.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') + await data.async_save() provider = hass_auth.HassAuthProvider(hass, None, {}) flow = hass_auth.LoginFlow(provider) result = await flow.async_step_init() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - with patch.object(provider, '_auth_data', return_value=data): - result = await flow.async_step_init({ - 'username': 'incorrect-user', - 'password': 'test-pass', - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['errors']['base'] == 'invalid_auth' + result = await flow.async_step_init({ + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' - result = await flow.async_step_init({ - 'username': 'test-user', - 'password': 'incorrect-pass', - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['errors']['base'] == 'invalid_auth' + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' - result = await flow.async_step_init({ - 'username': 'test-user', - 'password': 'test-pass', - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_saving_loading(hass): +async def test_saving_loading(data, hass): """Test saving and loading JSON.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') data.add_user('second-user', 'second-pass') + await data.async_save() - with patch(JSON__OPEN_PATH, mock_open(), create=True) as mock_write: - await hass.async_add_job(data.save) - - # Mock open calls are: open file, context enter, write, context leave - written = mock_write.mock_calls[2][1][0] - - with patch('os.path.isfile', return_value=True), \ - patch(JSON__OPEN_PATH, mock_open(read_data=written), create=True): - await hass.async_add_job(hass_auth.load_data, MOCK_PATH) - + data = hass_auth.Data(hass) + await data.async_load() data.validate_login('test-user', 'test-pass') data.validate_login('second-user', 'second-pass') diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth_providers/test_insecure_example.py index 0b481f93099..3377a60c45b 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth_providers/test_insecure_example.py @@ -11,15 +11,15 @@ from tests.common import mock_coro @pytest.fixture -def store(): +def store(hass): """Mock store.""" - return auth.AuthStore(Mock()) + return auth.AuthStore(hass) @pytest.fixture -def provider(store): +def provider(hass, store): """Mock provider.""" - return insecure_example.ExampleAuthProvider(None, store, { + return insecure_example.ExampleAuthProvider(hass, store, { 'type': 'insecure_example', 'users': [ { diff --git a/tests/auth_providers/test_legacy_api_password.py b/tests/auth_providers/test_legacy_api_password.py new file mode 100644 index 00000000000..7a8f17894aa --- /dev/null +++ b/tests/auth_providers/test_legacy_api_password.py @@ -0,0 +1,67 @@ +"""Tests for the legacy_api_password auth provider.""" +from unittest.mock import Mock + +import pytest + +from homeassistant import auth +from homeassistant.auth_providers import legacy_api_password + + +@pytest.fixture +def store(hass): + """Mock store.""" + return auth.AuthStore(hass) + + +@pytest.fixture +def provider(hass, store): + """Mock provider.""" + return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, { + 'type': 'legacy_api_password', + }) + + +async def test_create_new_credential(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({}) + assert credentials.data["username"] is legacy_api_password.LEGACY_USER + assert credentials.is_new is True + + +async def test_only_one_credentials(store, provider): + """Call create twice will return same credential.""" + credentials = await provider.async_get_or_create_credentials({}) + await store.async_get_or_create_user(credentials, provider) + credentials2 = await provider.async_get_or_create_credentials({}) + assert credentials2.data["username"] is legacy_api_password.LEGACY_USER + assert credentials2.id is credentials.id + assert credentials2.is_new is False + + +async def test_verify_not_load(hass, provider): + """Test we raise if http module not load.""" + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password=None) + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + + +async def test_verify_login(hass, provider): + """Test we raise if http module not load.""" + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + with pytest.raises(legacy_api_password.InvalidAuthError): + provider.async_validate_login('invalid-password') + + +async def test_utf_8_username_password(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({ + 'username': '🎉', + 'password': '😎', + }) + assert credentials.is_new is True diff --git a/tests/common.py b/tests/common.py index 556935a6ac1..3a51cd3e059 100644 --- a/tests/common.py +++ b/tests/common.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import functools as ft +import json import os import sys from unittest.mock import patch, MagicMock, Mock @@ -14,8 +15,8 @@ from homeassistant import auth, core as ha, data_entry_flow, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( - intent, entity, restore_state, entity_registry, - entity_platform) + intent, entity, restore_state, entity_registry, + entity_platform, storage) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util import homeassistant.util.yaml as yaml @@ -110,8 +111,6 @@ def get_test_home_assistant(): def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) - hass.config_entries = config_entries.ConfigEntries(hass, {}) - hass.config_entries._entries = [] hass.config.async_load = Mock() store = auth.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}) @@ -137,6 +136,10 @@ def async_test_home_assistant(loop): hass.config.units = METRIC_SYSTEM hass.config.skip_pip = True + hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries._entries = [] + hass.config_entries._store._async_ensure_stop_listener = lambda: None + hass.state = ha.CoreState.running # Mock async_start @@ -317,7 +320,8 @@ class MockUser(auth.User): def add_to_auth_manager(self, auth_mgr): """Test helper to add entry to hass.""" - auth_mgr._store.users[self.id] = self + ensure_auth_manager_loaded(auth_mgr) + auth_mgr._store._users[self.id] = self return self @@ -325,10 +329,10 @@ class MockUser(auth.User): 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 = {} + if store._clients is None: + store._clients = {} + if store._users is None: + store._users = {} class MockModule(object): @@ -703,3 +707,51 @@ class MockEntity(entity.Entity): if attr in self._values: return self._values[attr] return getattr(super(), attr) + + +@contextmanager +def mock_storage(data=None): + """Mock storage. + + Data is a dict {'key': {'version': version, 'data': data}} + + Written data will be converted to JSON to ensure JSON parsing works. + """ + if data is None: + data = {} + + orig_load = storage.Store._async_load + + async def mock_async_load(store): + """Mock version of load.""" + if store._data is None: + # No data to load + if store.key not in data: + return None + + store._data = data.get(store.key) + + # Route through original load so that we trigger migration + loaded = await orig_load(store) + _LOGGER.info('Loading data for %s: %s', store.key, loaded) + return loaded + + def mock_write_data(store, path, data_to_write): + """Mock version of write data.""" + # To ensure that the data can be serialized + _LOGGER.info('Writing data to %s: %s', store.key, data_to_write) + data[store.key] = json.loads(json.dumps(data_to_write)) + + with patch('homeassistant.helpers.storage.Store._async_load', + side_effect=mock_async_load, autospec=True), \ + patch('homeassistant.helpers.storage.Store._write_data', + side_effect=mock_write_data, autospec=True): + yield data + + +async def flush_store(store): + """Make sure all delayed writes of a store are written.""" + if store._data is None: + return + + await store._async_handle_write_data() diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index f0b205ff5ce..21719c12569 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -34,7 +34,7 @@ async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, }) client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET, redirect_uris=[CLIENT_REDIRECT_URI]) - hass.auth._store.clients[client.id] = client + 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/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 255d482d584..5db77331cd4 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -137,6 +137,37 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual("cool", state.state) + def test_set_operation_with_power_command(self): + """Test setting of new operation mode with power command enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['power_command_topic'] = 'power-command' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + climate.set_operation_mode(self.hass, "on", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("on", state.attributes.get('operation_mode')) + self.assertEqual("on", state.state) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('power-command', 'ON', 0, False), + unittest.mock.call('mode-topic', 'on', 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + + climate.set_operation_mode(self.hass, "off", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('power-command', 'OFF', 0, False), + unittest.mock.call('mode-topic', 'off', 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) @@ -241,6 +272,8 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual(21, state.attributes.get('temperature')) climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('heat', state.attributes.get('operation_mode')) self.mock_publish.async_publish.assert_called_once_with( 'mode-topic', 'heat', 0, False) self.mock_publish.async_publish.reset_mock() @@ -252,6 +285,21 @@ class TestMQTTClimate(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'temperature-topic', 47, 0, False) + # also test directly supplying the operation mode to set_temperature + self.mock_publish.async_publish.reset_mock() + climate.set_temperature(self.hass, temperature=21, + operation_mode="cool", + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('cool', state.attributes.get('operation_mode')) + self.assertEqual(21, state.attributes.get('temperature')) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('mode-topic', 'cool', 0, False), + unittest.mock.call('temperature-topic', 21, 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + def test_set_target_temperature_pessimistic(self): """Test setting the target temperature.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -508,13 +556,28 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("on", state.attributes.get('swing_mode')) - # Temperature + # Temperature - with valid value self.assertEqual(21, state.attributes.get('temperature')) fire_mqtt_message(self.hass, 'temperature-state', '"1031"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(1031, state.attributes.get('temperature')) + # Temperature - with invalid value + with self.assertLogs(level='ERROR') as log: + fire_mqtt_message(self.hass, 'temperature-state', '"-INVALID-"') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + # make sure, the invalid value gets logged... + self.assertEqual(len(log.output), 1) + self.assertEqual(len(log.records), 1) + self.assertIn( + "Could not parse temperature from -INVALID-", + log.output[0] + ) + # ... but the actual value stays unchanged. + self.assertEqual(1031, state.attributes.get('temperature')) + # Away Mode self.assertEqual('off', state.attributes.get('away_mode')) fire_mqtt_message(self.hass, 'away-state', '"ON"') @@ -522,6 +585,17 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('away_mode')) + # Away Mode with JSON values + fire_mqtt_message(self.hass, 'away-state', 'false') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + fire_mqtt_message(self.hass, 'away-state', 'true') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + # Hold Mode self.assertEqual(None, state.attributes.get('hold_mode')) fire_mqtt_message(self.hass, 'hold-state', """ @@ -538,6 +612,12 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('aux_heat')) + # anything other than 'switchmeon' should turn Aux mode off + fire_mqtt_message(self.hass, 'aux-state', 'somerandomstring') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + # Current temperature fire_mqtt_message(self.hass, 'current-temperature', '"74656"') self.hass.block_till_done() diff --git a/tests/components/climate/test_zwave.py b/tests/components/climate/test_zwave.py index fbd6ea7f798..39a85ab493f 100644 --- a/tests/components/climate/test_zwave.py +++ b/tests/components/climate/test_zwave.py @@ -1,9 +1,9 @@ """Test Z-Wave climate devices.""" import pytest -from homeassistant.components.climate import zwave +from homeassistant.components.climate import zwave, STATE_COOL, STATE_HEAT from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from tests.mock.zwave import ( MockNode, MockValue, MockEntityValues, value_changed) @@ -46,6 +46,24 @@ def device_zxt_120(hass, mock_openzwave): yield device +@pytest.fixture +def device_mapping(hass, mock_openzwave): + """Fixture to provide a precreated climate device. Test state mapping.""" + node = MockNode() + values = MockEntityValues( + primary=MockValue(data=1, node=node), + temperature=MockValue(data=5, node=node, units=None), + mode=MockValue(data='Off', data_items=['Off', 'Cool', 'Heat'], + node=node), + fan_mode=MockValue(data='test2', data_items=[3, 4, 5], node=node), + operating_state=MockValue(data=6, node=node), + fan_state=MockValue(data=7, node=node), + ) + device = zwave.get_device(hass, node=node, values=values, node_config={}) + + yield device + + def test_zxt_120_swing_mode(device_zxt_120): """Test operation of the zxt 120 swing mode.""" device = device_zxt_120 @@ -109,6 +127,18 @@ def test_operation_value_set(device): assert device.values.mode.data == 'test_set' +def test_operation_value_set_mapping(device_mapping): + """Test values changed for climate device. Mapping.""" + device = device_mapping + assert device.values.mode.data == 'Off' + device.set_operation_mode(STATE_HEAT) + assert device.values.mode.data == 'Heat' + device.set_operation_mode(STATE_COOL) + assert device.values.mode.data == 'Cool' + device.set_operation_mode(STATE_OFF) + assert device.values.mode.data == 'Off' + + def test_fan_mode_value_set(device): """Test values changed for climate device.""" assert device.values.fan_mode.data == 'test2' @@ -140,6 +170,21 @@ def test_operation_value_changed(device): assert device.current_operation == 'test_updated' +def test_operation_value_changed_mapping(device_mapping): + """Test values changed for climate device. Mapping.""" + device = device_mapping + assert device.current_operation == 'off' + device.values.mode.data = 'Heat' + value_changed(device.values.mode) + assert device.current_operation == STATE_HEAT + device.values.mode.data = 'Cool' + value_changed(device.values.mode) + assert device.current_operation == STATE_COOL + device.values.mode.data = 'Off' + value_changed(device.values.mode) + assert device.current_operation == STATE_OFF + + def test_fan_mode_value_changed(device): """Test values changed for climate device.""" assert device.current_fan_mode == 'test2' diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 711c38443f2..59da90cc75b 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -13,7 +13,9 @@ from homeassistant.components.homekit.const import ( BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER, MANUFACTURER, SERV_ACCESSORY_INFO) -from homeassistant.const import __version__, ATTR_NOW, EVENT_TIME_CHANGED +from homeassistant.const import ( + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_NOW, + EVENT_TIME_CHANGED) import homeassistant.util.dt as dt_util @@ -56,8 +58,7 @@ async def test_home_accessory(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, 'Home Accessory', - entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, 'Home Accessory', entity_id, 2, None) assert acc.hass == hass assert acc.display_name == 'Home Accessory' assert acc.aid == 2 @@ -89,11 +90,60 @@ async def test_home_accessory(hass, hk_driver): # Test model name from domain entity_id = 'test_model.demo' - acc = HomeAccessory('hass', hk_driver, 'test_name', entity_id, 2, None) + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = HomeAccessory(hass, hk_driver, 'test_name', entity_id, 2, None) serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_MODEL).value == 'Test Model' +async def test_battery_service(hass, hk_driver): + """Test battery service.""" + entity_id = 'homekit.accessory' + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 50}) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + assert acc._char_battery.value == 0 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc._char_battery.value == 50 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 15}) + await hass.async_block_till_done() + assert acc._char_battery.value == 15 + assert acc._char_low_battery.value == 1 + assert acc._char_charging.value == 2 + + # Test charging + hass.states.async_set(entity_id, None, { + ATTR_BATTERY_LEVEL: 10, ATTR_BATTERY_CHARGING: True}) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + assert acc._char_battery.value == 0 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc._char_battery.value == 10 + assert acc._char_low_battery.value == 1 + assert acc._char_charging.value == 1 + + hass.states.async_set(entity_id, None, { + ATTR_BATTERY_LEVEL: 100, ATTR_BATTERY_CHARGING: False}) + await hass.async_block_till_done() + assert acc._char_battery.value == 100 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 0 + + def test_home_bridge(hk_driver): """Test HomeBridge class.""" bridge = HomeBridge('hass', hk_driver) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 4de68057084..92f8736d1fe 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -34,7 +34,7 @@ def test_not_supported_media_player(): # selected mode for entity not supported config = {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}} entity_state = State('media_player.demo', 'on') - get_accessory(None, None, entity_state, 2, config) is None + assert get_accessory(None, None, entity_state, 2, config) is None # no supported modes for entity entity_state = State('media_player.demo', 'on') @@ -62,7 +62,7 @@ def test_customize_options(config, name): {ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF}, {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}}), - ('SecuritySystem', 'alarm_control_panel.test', 'armed', {}, + ('SecuritySystem', 'alarm_control_panel.test', 'armed_away', {}, {ATTR_CODE: '1234'}), ('Thermostat', 'climate.test', 'auto', {}, {}), ('Thermostat', 'climate.test', 'auto', diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 08e8da7857e..cc0370f01b1 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -4,17 +4,17 @@ from unittest.mock import patch, ANY, Mock import pytest from homeassistant import setup -from homeassistant.core import State from homeassistant.components.homekit import ( generate_aid, HomeKit, STATUS_READY, STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT) from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( CONF_AUTO_START, DEFAULT_PORT, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) -from homeassistant.helpers.entityfilter import generate_filter from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import State +from homeassistant.helpers.entityfilter import generate_filter from tests.components.homekit.common import patch_debounce diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index c69ddacd328..04ed5df5702 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -19,7 +19,7 @@ def cls(): patcher = patch_debounce() patcher.start() _import = __import__('homeassistant.components.homekit.type_covers', - fromlist=['GarageDoorOpener', 'WindowCovering,', + fromlist=['GarageDoorOpener', 'WindowCovering', 'WindowCoveringBasic']) patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage']) yield patcher_tuple(window=_import.WindowCovering, diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index ba7d4ccdcf0..87a481ff06f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -5,11 +5,10 @@ import pytest from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, - SUPPORT_DIRECTION, SUPPORT_OSCILLATE) + DOMAIN, SUPPORT_DIRECTION, SUPPORT_OSCILLATE) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF, - STATE_UNKNOWN, SERVICE_TURN_ON, SERVICE_TURN_OFF) + STATE_UNKNOWN) from tests.common import async_mock_service from tests.components.homekit.common import patch_debounce @@ -31,8 +30,7 @@ async def test_fan_basic(hass, hk_driver, cls): """Test fan with char state.""" entity_id = 'fan.demo' - hass.states.async_set(entity_id, STATE_ON, - {ATTR_SUPPORTED_FEATURES: 0}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) @@ -44,8 +42,7 @@ async def test_fan_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF, - {ATTR_SUPPORTED_FEATURES: 0}) + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() assert acc.char_active.value == 0 @@ -58,8 +55,8 @@ async def test_fan_basic(hass, hk_driver, cls): assert acc.char_active.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) - call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') await hass.async_add_job(acc.char_active.client_update_value, 1) await hass.async_block_till_done() @@ -97,8 +94,7 @@ async def test_fan_direction(hass, hk_driver, cls): assert acc.char_direction.value == 1 # Set from HomeKit - call_set_direction = async_mock_service(hass, DOMAIN, - SERVICE_SET_DIRECTION) + call_set_direction = async_mock_service(hass, DOMAIN, 'set_direction') await hass.async_add_job(acc.char_direction.client_update_value, 0) await hass.async_block_till_done() @@ -128,13 +124,12 @@ async def test_fan_oscillate(hass, hk_driver, cls): await hass.async_block_till_done() assert acc.char_swing.value == 0 - hass.states.async_set(entity_id, STATE_ON, - {ATTR_OSCILLATING: True}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True}) await hass.async_block_till_done() assert acc.char_swing.value == 1 # Set from HomeKit - call_oscillate = async_mock_service(hass, DOMAIN, SERVICE_OSCILLATE) + call_oscillate = async_mock_service(hass, DOMAIN, 'oscillate') await hass.async_add_job(acc.char_swing.client_update_value, 0) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index a9a5f1c3ece..aab6274f484 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -30,8 +30,7 @@ async def test_light_basic(hass, hk_driver, cls): """Test light with char state.""" entity_id = 'light.demo' - hass.states.async_set(entity_id, STATE_ON, - {ATTR_SUPPORTED_FEATURES: 0}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) @@ -43,8 +42,7 @@ async def test_light_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert acc.char_on.value == 1 - hass.states.async_set(entity_id, STATE_OFF, - {ATTR_SUPPORTED_FEATURES: 0}) + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() assert acc.char_on.value == 0 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 4076b1f8a89..681cbba7252 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,16 +1,14 @@ """Test different accessory types: Media Players.""" -from homeassistant.components.media_player import ( - ATTR_MEDIA_VOLUME_MUTED, DOMAIN) -from homeassistant.components.homekit.type_media_players import MediaPlayer from homeassistant.components.homekit.const import ( CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE) +from homeassistant.components.homekit.type_media_players import MediaPlayer +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_MUTE, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, - STATE_PLAYING) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PAUSED, STATE_PLAYING) from tests.common import async_mock_service @@ -59,12 +57,12 @@ async def test_media_player_set_state(hass, hk_driver): assert acc.chars[FEATURE_PLAY_STOP].value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) - call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) - call_media_play = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) - call_media_pause = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) - call_media_stop = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_STOP) - call_toggle_mute = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + call_media_play = async_mock_service(hass, DOMAIN, 'media_play') + call_media_pause = async_mock_service(hass, DOMAIN, 'media_pause') + call_media_stop = async_mock_service(hass, DOMAIN, 'media_stop') + call_toggle_mute = async_mock_service(hass, DOMAIN, 'volume_mute') await hass.async_add_job(acc.chars[FEATURE_ON_OFF] .client_update_value, True) diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 54ecbcb196f..901a8e76856 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -14,8 +14,7 @@ async def test_temperature(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = TemperatureSensor(hass, hk_driver, 'Temperature', - entity_id, 2, None) + acc = TemperatureSensor(hass, hk_driver, 'Temperature', entity_id, 2, None) await hass.async_add_job(acc.run) assert acc.aid == 2 @@ -70,8 +69,7 @@ async def test_air_quality(hass, hk_driver): hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = AirQualitySensor(hass, hk_driver, 'Air Quality', - entity_id, 2, None) + acc = AirQualitySensor(hass, hk_driver, 'Air Quality', entity_id, 2, None) await hass.async_add_job(acc.run) assert acc.aid == 2 diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 3a09d2715d1..c2b80226508 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -1,9 +1,9 @@ """Test different accessory types: Switches.""" import pytest -from homeassistant.core import split_entity_id from homeassistant.components.homekit.type_switches import Outlet, Switch from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import split_entity_id from tests.common import async_mock_service diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 00e3e2d22fc..45c340e58c4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -13,7 +13,7 @@ from homeassistant.components.homekit.const import ( PROP_MAX_VALUE, PROP_MIN_VALUE) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + CONF_TEMPERATURE_UNIT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import async_mock_service from tests.components.homekit.common import patch_debounce @@ -323,7 +323,7 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls): # support_auto = True hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) await hass.async_block_till_done() - with patch.object(hass.config.units, 'temperature_unit', + with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) await hass.async_add_job(acc.run) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index fa9fddee5fc..9be92b817be 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -2,7 +2,6 @@ import pytest import voluptuous as vol -from homeassistant.core import State from homeassistant.components.homekit.const import ( CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, TYPE_OUTLET) @@ -17,6 +16,7 @@ from homeassistant.components.persistent_notification import ( from homeassistant.const import ( ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import State from tests.common import async_mock_service diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index a44d17d513d..3e5eed4c924 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,20 +1,23 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access from ipaddress import ip_network -from unittest.mock import patch +from unittest.mock import patch, Mock +import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -import pytest +from homeassistant.auth import AccessToken, RefreshToken +from homeassistant.components.http.auth import setup_auth +from homeassistant.components.http.const import KEY_AUTHENTICATED +from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component -from homeassistant.components.http.auth import setup_auth -from homeassistant.components.http.real_ip import setup_real_ip -from homeassistant.components.http.const import KEY_AUTHENTICATED - from . import mock_real_ip + +ACCESS_TOKEN = 'tk.1234' + API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -36,12 +39,34 @@ async def mock_handler(request): return web.Response(status=200) +def mock_async_get_access_token(token): + """Return if token is valid.""" + if token == ACCESS_TOKEN: + return Mock(spec=AccessToken, + token=ACCESS_TOKEN, + refresh_token=Mock(spec=RefreshToken)) + else: + return None + + @pytest.fixture def app(): """Fixture to setup a web.Application.""" app = web.Application() + mock_auth = Mock(async_get_access_token=mock_async_get_access_token) + app['hass'] = Mock(auth=mock_auth) + app.router.add_get('/', mock_handler) + setup_real_ip(app, False, []) + return app + + +@pytest.fixture +def app2(): + """Fixture to setup a web.Application without real_ip middleware.""" + app = web.Application() + mock_auth = Mock(async_get_access_token=mock_async_get_access_token) + app['hass'] = Mock(auth=mock_auth) app.router.add_get('/', mock_handler) - setup_real_ip(app, False) return app @@ -57,7 +82,7 @@ async def test_auth_middleware_loaded_by_default(hass): async def test_access_without_password(app, aiohttp_client): """Test access without password.""" - setup_auth(app, [], None) + setup_auth(app, [], False, api_password=None) client = await aiohttp_client(app) resp = await client.get('/') @@ -65,8 +90,8 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client): - """Test access with password in URL.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in header.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -79,8 +104,8 @@ async def test_access_with_password_in_header(app, aiohttp_client): async def test_access_with_password_in_query(app, aiohttp_client): - """Test access without password.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in URL.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) resp = await client.get('/', params={ @@ -99,7 +124,7 @@ async def test_access_with_password_in_query(app, aiohttp_client): async def test_basic_auth_works(app, aiohttp_client): """Test access with basic authentication.""" - setup_auth(app, [], API_PASSWORD) + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -125,15 +150,12 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client): """Test access with an untrusted ip address.""" - app = web.Application() - app.router.add_get('/', mock_handler) + setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') - setup_auth(app, TRUSTED_NETWORKS, 'some-pass') - - set_mock_ip = mock_real_ip(app) - client = await aiohttp_client(app) + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) @@ -146,3 +168,94 @@ async def test_access_with_trusted_ip(aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + + +async def test_auth_active_access_with_access_token_in_header( + app, aiohttp_client): + """Test access with access token in header.""" + setup_auth(app, [], True, api_password=None) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={'Authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'AUTHORIZATION': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'authorization': 'Bearer {}'.format(ACCESS_TOKEN)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'Authorization': ACCESS_TOKEN}) + assert req.status == 401 + + req = await client.get( + '/', headers={'Authorization': 'BEARER {}'.format(ACCESS_TOKEN)}) + assert req.status == 401 + + req = await client.get( + '/', headers={'Authorization': 'Bearer wrong-pass'}) + assert req.status == 401 + + +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): + """Test access with an untrusted ip address.""" + setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) + + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) + + for remote_addr in UNTRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) + + for remote_addr in TRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) + + +async def test_auth_active_blocked_api_password_access(app, aiohttp_client): + """Test access using api_password should be blocked when auth.active.""" + setup_auth(app, [], True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 401 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 401 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 401 + + +async def test_auth_legacy_support_api_password_access(app, aiohttp_client): + """Test access using api_password if auth.support_legacy.""" + setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 200 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 200 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 200 diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py index 61846eb94c2..6cf6fec6bce 100644 --- a/tests/components/http/test_real_ip.py +++ b/tests/components/http/test_real_ip.py @@ -1,6 +1,7 @@ """Test real IP middleware.""" from aiohttp import web from aiohttp.hdrs import X_FORWARDED_FOR +from ipaddress import ip_network from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_REAL_IP @@ -15,7 +16,7 @@ async def test_ignore_x_forwarded_for(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) - setup_real_ip(app, False) + setup_real_ip(app, False, []) mock_api_client = await aiohttp_client(app) @@ -27,11 +28,27 @@ async def test_ignore_x_forwarded_for(aiohttp_client): assert text != '255.255.255.255' -async def test_use_x_forwarded_for(aiohttp_client): +async def test_use_x_forwarded_for_without_trusted_proxy(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) - setup_real_ip(app, True) + setup_real_ip(app, True, []) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text != '255.255.255.255' + + +async def test_use_x_forwarded_for_with_trusted_proxy(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) mock_api_client = await aiohttp_client(app) @@ -41,3 +58,51 @@ async def test_use_x_forwarded_for(aiohttp_client): assert resp.status == 200 text = await resp.text() assert text == '255.255.255.255' + + +async def test_use_x_forwarded_for_with_untrusted_proxy(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('1.1.1.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text != '255.255.255.255' + + +async def test_use_x_forwarded_for_with_spoofed_header(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '222.222.222.222, 255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text == '255.255.255.255' + + +async def test_use_x_forwarded_for_with_nonsense_header(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: 'This value is invalid' + }) + assert resp.status == 200 + text = await resp.text() + assert text == '127.0.0.1' diff --git a/tests/components/light/test_tradfri.py b/tests/components/light/test_tradfri.py index 8ef5d17452a..12c596f3f09 100644 --- a/tests/components/light/test_tradfri.py +++ b/tests/components/light/test_tradfri.py @@ -229,6 +229,7 @@ async def setup_gateway(hass, mock_gateway, mock_api, patch('pytradfri.api.aiocoap_api.APIFactory.request', mock_api), \ patch('pytradfri.Gateway', return_value=mock_gateway), \ patch.object(tradfri, 'load_json', return_value=known_hosts), \ + patch.object(tradfri, 'save_json'), \ patch.object(hass.components.configurator, 'request_config', request_config): diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index b5baf8b078b..349067f7cd3 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -1,11 +1,16 @@ """Tests for samsungtv Components.""" +import asyncio import unittest +from unittest.mock import call, patch, MagicMock from subprocess import CalledProcessError from asynctest import mock +import pytest + import tests.common -from homeassistant.components.media_player import SUPPORT_TURN_ON +from homeassistant.components.media_player import SUPPORT_TURN_ON, \ + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL from homeassistant.components.media_player.samsungtv import setup_platform, \ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \ @@ -301,3 +306,59 @@ class TestSamsungTv(unittest.TestCase): self.device._mac = "fake" self.device.turn_on() self.device._wol.send_magic_packet.assert_called_once_with("fake") + + +@pytest.fixture +def samsung_mock(): + """Mock samsungctl.""" + with patch.dict('sys.modules', { + 'samsungctl': MagicMock(), + }): + yield + + +async def test_play_media(hass, samsung_mock): + """Test for play_media.""" + asyncio_sleep = asyncio.sleep + sleeps = [] + + async def sleep(duration, loop): + sleeps.append(duration) + await asyncio_sleep(0, loop=loop) + + with patch('asyncio.sleep', new=sleep): + device = SamsungTVDevice(**WORKING_CONFIG) + device.hass = hass + + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, "576") + + exp = [call("KEY_5"), call("KEY_7"), call("KEY_6")] + assert device.send_key.call_args_list == exp + assert len(sleeps) == 3 + + +async def test_play_media_invalid_type(hass, samsung_mock): + """Test for play_media with invalid media type.""" + url = "https://example.com" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_URL, url) + assert device.send_key.call_count == 0 + + +async def test_play_media_channel_as_string(hass, samsung_mock): + """Test for play_media with invalid channel as string.""" + url = "https://example.com" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, url) + assert device.send_key.call_count == 0 + + +async def test_play_media_channel_as_non_positive(hass, samsung_mock): + """Test for play_media with invalid channel as non positive integer.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, "-4") + assert device.send_key.call_count == 0 diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1dd29909ffd..ed6c77f676c 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -52,12 +52,21 @@ def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): @asyncio.coroutine def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): """Test for a valid component.""" + invalid_component = "timer" + mock_load_platform.return_value = mock_coro() yield from async_start(hass, 'homeassistant', {}) - async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', '{}') + async_fire_mqtt_message(hass, 'homeassistant/{}/bla/config'.format( + invalid_component + ), '{}') + yield from hass.async_block_till_done() - assert 'Component climate is not supported' in caplog.text + + assert 'Component {} is not supported'.format( + invalid_component + ) in caplog.text + assert not mock_load_platform.called @@ -94,6 +103,27 @@ def test_discover_fan(hass, mqtt_mock, caplog): assert ('fan', 'bla') in hass.data[ALREADY_DISCOVERED] +@asyncio.coroutine +def test_discover_climate(hass, mqtt_mock, caplog): + """Test discovering an MQTT climate component.""" + yield from async_start(hass, 'homeassistant', {}) + + data = ( + '{ "name": "ClimateTest",' + ' "current_temperature_topic": "climate/bla/current_temp",' + ' "temperature_command_topic": "climate/bla/target_temp" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', data) + yield from hass.async_block_till_done() + + state = hass.states.get('climate.ClimateTest') + + assert state is not None + assert state.name == 'ClimateTest' + assert ('climate', 'bla') in hass.data[ALREADY_DISCOVERED] + + @asyncio.coroutine def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): """Test sending in correct JSON with optional node_id included.""" diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index e336a28eb03..49744421c72 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -12,7 +12,7 @@ def prometheus_client(loop, hass, aiohttp_client): assert loop.run_until_complete(async_setup_component( hass, prometheus.DOMAIN, - {}, + {prometheus.DOMAIN: {}}, )) return loop.run_until_complete(aiohttp_client(hass.http.app)) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index fbd8584a7d1..6ea90bcdb88 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -77,7 +77,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): assert mock_process_wrong_login.called assert msg['type'] == wapi.TYPE_AUTH_INVALID - assert msg['message'] == 'Invalid password' + assert msg['message'] == 'Invalid access token or password' @asyncio.coroutine @@ -316,47 +316,103 @@ def test_unknown_command(websocket_client): assert msg['error']['code'] == wapi.ERR_UNKNOWN_COMMAND -async def test_auth_with_token(hass, aiohttp_client, hass_access_token): +async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { - 'http': { - 'api_password': API_PASSWORD - } - }) + 'http': { + 'api_password': API_PASSWORD + } + }) client = await aiohttp_client(hass.http.app) async with client.ws_connect(wapi.URL) as ws: - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': wapi.TYPE_AUTH, - 'access_token': hass_access_token.token - }) + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token.token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + +async def test_auth_active_with_password_not_allow(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + +async def test_auth_legacy_support_with_password(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active', + return_value=True),\ + patch('homeassistant.auth.AuthManager.support_legacy', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK async def test_auth_with_invalid_token(hass, aiohttp_client): """Test authenticating with a token.""" assert await async_setup_component(hass, 'websocket_api', { - 'http': { - 'api_password': API_PASSWORD - } - }) + 'http': { + 'api_password': API_PASSWORD + } + }) client = await aiohttp_client(hass.http.app) async with client.ws_connect(wapi.URL) as ws: - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': wapi.TYPE_AUTH, - 'access_token': 'incorrect' - }) + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': 'incorrect' + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py index 7faa033e0a8..41687451cd6 100644 --- a/tests/components/weather/test_darksky.py +++ b/tests/components/weather/test_darksky.py @@ -48,4 +48,4 @@ class TestDarkSky(unittest.TestCase): self.assertEqual(mock_get_forecast.call_count, 1) state = self.hass.states.get('weather.test') - self.assertEqual(state.state, 'Clear') + self.assertEqual(state.state, 'sunny') diff --git a/tests/conftest.py b/tests/conftest.py index 4d619c5ef61..0a350b62fc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,8 @@ from homeassistant import util from homeassistant.util import location from tests.common import ( - async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro) + async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro, + mock_storage as mock_storage) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -59,7 +60,14 @@ def verify_cleanup(): @pytest.fixture -def hass(loop): +def hass_storage(): + """Fixture to mock storage.""" + with mock_storage() as stored_data: + yield stored_data + + +@pytest.fixture +def hass(loop, hass_storage): """Fixture to provide a test instance of HASS.""" hass = loop.run_until_complete(async_test_home_assistant(loop)) diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py new file mode 100644 index 00000000000..f414eaec97c --- /dev/null +++ b/tests/helpers/test_storage.py @@ -0,0 +1,179 @@ +"""Tests for the storage helper.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import storage +from homeassistant.util import dt + +from tests.common import async_fire_time_changed, mock_coro + + +MOCK_VERSION = 1 +MOCK_KEY = 'storage-test' +MOCK_DATA = {'hello': 'world'} + + +@pytest.fixture +def store(hass): + """Fixture of a store that prevents writing on HASS stop.""" + yield storage.Store(hass, MOCK_VERSION, MOCK_KEY) + + +async def test_loading(hass, store): + """Test we can save and load data.""" + await store.async_save(MOCK_DATA) + data = await store.async_load() + assert data == MOCK_DATA + + +async def test_loading_non_existing(hass, store): + """Test we can save and load data.""" + with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): + data = await store.async_load() + assert data is None + + +async def test_loading_parallel(hass, store, hass_storage, caplog): + """Test we can save and load data.""" + hass_storage[store.key] = { + 'version': MOCK_VERSION, + 'data': MOCK_DATA, + } + + results = await asyncio.gather( + store.async_load(), + store.async_load() + ) + + assert results[0] is MOCK_DATA + assert results[1] is MOCK_DATA + assert caplog.text.count('Loading data for {}'.format(store.key)) + + +async def test_saving_with_delay(hass, store, hass_storage): + """Test saving data after a delay.""" + await store.async_save(MOCK_DATA, delay=1) + assert store.key not in hass_storage + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': MOCK_DATA, + } + + +async def test_saving_on_stop(hass, hass_storage): + """Test delayed saves trigger when we quit Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + await store.async_save(MOCK_DATA, delay=1) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': MOCK_DATA, + } + + +async def test_loading_while_delay(hass, store, hass_storage): + """Test we load new data even if not written yet.""" + await store.async_save({'delay': 'no'}) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + await store.async_save({'delay': 'yes'}, delay=1) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + data = await store.async_load() + assert data == {'delay': 'yes'} + + +async def test_writing_while_writing_delay(hass, store, hass_storage): + """Test a write while a write with delay is active.""" + await store.async_save({'delay': 'yes'}, delay=1) + assert store.key not in hass_storage + await store.async_save({'delay': 'no'}) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + data = await store.async_load() + assert data == {'delay': 'no'} + + +async def test_migrator_no_existing_config(hass, store, hass_storage): + """Test migrator with no existing config.""" + with patch('os.path.isfile', return_value=False), \ + patch.object(store, 'async_load', + return_value=mock_coro({'cur': 'config'})): + data = await storage.async_migrator( + hass, 'old-path', store) + + assert data == {'cur': 'config'} + assert store.key not in hass_storage + + +async def test_migrator_existing_config(hass, store, hass_storage): + """Test migrating existing config.""" + with patch('os.path.isfile', return_value=True), \ + patch('os.remove') as mock_remove, \ + patch('homeassistant.util.json.load_json', + return_value={'old': 'config'}): + data = await storage.async_migrator( + hass, 'old-path', store) + + assert len(mock_remove.mock_calls) == 1 + assert data == {'old': 'config'} + assert hass_storage[store.key] == { + 'key': MOCK_KEY, + 'version': MOCK_VERSION, + 'data': data, + } + + +async def test_migrator_transforming_config(hass, store, hass_storage): + """Test migrating config to new format.""" + async def old_conf_migrate_func(old_config): + """Migrate old config to new format.""" + return {'new': old_config['old']} + + with patch('os.path.isfile', return_value=True), \ + patch('os.remove') as mock_remove, \ + patch('homeassistant.util.json.load_json', + return_value={'old': 'config'}): + data = await storage.async_migrator( + hass, 'old-path', store, + old_conf_migrate_func=old_conf_migrate_func) + + assert len(mock_remove.mock_calls) == 1 + assert data == {'new': 'config'} + assert hass_storage[store.key] == { + 'key': MOCK_KEY, + 'version': MOCK_VERSION, + 'data': data, + } diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index 2e837b06b58..e6aa7893f33 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -6,16 +6,21 @@ import pytest from homeassistant.scripts import auth as script_auth from homeassistant.auth_providers import homeassistant as hass_auth -MOCK_PATH = '/bla/users.json' + +@pytest.fixture +def data(hass): + """Create a loaded data class.""" + data = hass_auth.Data(hass) + hass.loop.run_until_complete(data.async_load()) + return data -def test_list_user(capsys): +async def test_list_user(data, capsys): """Test we can list users.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') data.add_user('second-user', 'second-pass') - script_auth.list_users(data, None) + await script_auth.list_users(data, None) captured = capsys.readouterr() @@ -28,15 +33,12 @@ def test_list_user(capsys): ]) -def test_add_user(capsys): +async def test_add_user(data, capsys, hass_storage): """Test we can add a user.""" - data = hass_auth.Data(MOCK_PATH, None) + await script_auth.add_user( + data, Mock(username='paulus', password='test-pass')) - with patch.object(data, 'save') as mock_save: - script_auth.add_user( - data, Mock(username='paulus', password='test-pass')) - - assert len(mock_save.mock_calls) == 1 + assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() assert captured.out == 'User created\n' @@ -45,37 +47,34 @@ def test_add_user(capsys): data.validate_login('paulus', 'test-pass') -def test_validate_login(capsys): +async def test_validate_login(data, capsys): """Test we can validate a user login.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') - script_auth.validate_login( + await script_auth.validate_login( data, Mock(username='test-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth valid\n' - script_auth.validate_login( + await script_auth.validate_login( data, Mock(username='test-user', password='invalid-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' - script_auth.validate_login( + await script_auth.validate_login( data, Mock(username='invalid-user', password='test-pass')) captured = capsys.readouterr() assert captured.out == 'Auth invalid\n' -def test_change_password(capsys): +async def test_change_password(data, capsys, hass_storage): """Test we can change a password.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') - with patch.object(data, 'save') as mock_save: - script_auth.change_password( - data, Mock(username='test-user', new_password='new-pass')) + await script_auth.change_password( + data, Mock(username='test-user', new_password='new-pass')) - assert len(mock_save.mock_calls) == 1 + assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 captured = capsys.readouterr() assert captured.out == 'Password changed\n' data.validate_login('test-user', 'new-pass') @@ -83,18 +82,35 @@ def test_change_password(capsys): data.validate_login('test-user', 'test-pass') -def test_change_password_invalid_user(capsys): +async def test_change_password_invalid_user(data, capsys, hass_storage): """Test changing password of non-existing user.""" - data = hass_auth.Data(MOCK_PATH, None) data.add_user('test-user', 'test-pass') - with patch.object(data, 'save') as mock_save: - script_auth.change_password( - data, Mock(username='invalid-user', new_password='new-pass')) + await script_auth.change_password( + data, Mock(username='invalid-user', new_password='new-pass')) - assert len(mock_save.mock_calls) == 0 + assert hass_auth.STORAGE_KEY not in hass_storage captured = capsys.readouterr() assert captured.out == 'User not found\n' data.validate_login('test-user', 'test-pass') with pytest.raises(hass_auth.InvalidAuth): data.validate_login('invalid-user', 'new-pass') + + +def test_parsing_args(loop): + """Test we parse args correctly.""" + called = False + + async def mock_func(data, args2): + """Mock function to be called.""" + nonlocal called + called = True + assert data.hass.config.config_dir == '/somewhere/config' + assert args2 is args + + args = Mock(config='/somewhere/config', func=mock_func) + + with patch('argparse.ArgumentParser.parse_args', return_value=args): + script_auth.run(None) + + assert called, 'Mock function did not get called' diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 8dfc5db90e0..33154090286 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -160,6 +160,7 @@ class TestCheckConfig(unittest.TestCase): 'server_host': '0.0.0.0', 'server_port': 8123, 'trusted_networks': [], + 'trusted_proxies': [], 'use_x_forwarded_for': False} assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} assert res['secrets'] == {'http_pw': 'abc123'} diff --git a/tests/test_auth.py b/tests/test_auth.py index 4bbf218fd23..5b545223c15 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,14 +1,16 @@ """Tests for the Home Assistant auth module.""" -from unittest.mock import Mock +from datetime import timedelta +from unittest.mock import Mock, patch import pytest from homeassistant import auth, data_entry_flow -from tests.common import MockUser, ensure_auth_manager_loaded +from homeassistant.util import dt as dt_util +from tests.common import MockUser, ensure_auth_manager_loaded, flush_store @pytest.fixture -def mock_hass(): +def mock_hass(loop): """Hass mock with minimum amount of data set to make it work with auth.""" hass = Mock() hass.config.skip_pip = True @@ -53,9 +55,9 @@ async def test_auth_manager_from_config_validates_config_and_id(mock_hass): }] -async def test_create_new_user(mock_hass): +async def test_create_new_user(hass, hass_storage): """Test creating new user.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ + manager = await auth.auth_manager_from_config(hass, [{ 'type': 'insecure_example', 'users': [{ 'username': 'test-user', @@ -124,9 +126,9 @@ async def test_login_as_existing_user(mock_hass): assert user.name == 'Paulus' -async def test_linking_user_to_two_auth_providers(mock_hass): +async def test_linking_user_to_two_auth_providers(hass, hass_storage): """Test linking user to two auth providers.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ + manager = await auth.auth_manager_from_config(hass, [{ 'type': 'insecure_example', 'users': [{ 'username': 'test-user', @@ -157,3 +159,124 @@ async def test_linking_user_to_two_auth_providers(mock_hass): }) await manager.async_link_user(user, step['result']) assert len(user.credentials) == 2 + + +async def test_saving_loading(hass, hass_storage): + """Test storing and saving data. + + Creates one of each type that we store to test we restore correctly. + """ + manager = await auth.auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + user = await manager.async_get_or_create_user(step['result']) + + client = await manager.async_create_client( + 'test', redirect_uris=['https://example.com']) + + refresh_token = await manager.async_create_refresh_token(user, client.id) + + manager.async_create_access_token(refresh_token) + + await flush_store(manager._store._store) + + store2 = auth.AuthStore(hass) + users = await store2.async_get_users() + assert len(users) == 1 + assert users[0] == user + + clients = await store2.async_get_clients() + assert len(clients) == 1 + assert clients[0] == client + + +def test_access_token_expired(): + """Test that the expired property on access tokens work.""" + refresh_token = auth.RefreshToken( + user=None, + client_id='bla' + ) + + access_token = auth.AccessToken( + refresh_token=refresh_token + ) + + assert access_token.expired is False + + with patch('homeassistant.auth.dt_util.utcnow', + return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + assert access_token.expired is True + + almost_exp = dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION - timedelta(1) + with patch('homeassistant.auth.dt_util.utcnow', return_value=almost_exp): + assert access_token.expired is False + + +async def test_cannot_retrieve_expired_access_token(hass): + """Test that we cannot retrieve expired access tokens.""" + manager = await auth.auth_manager_from_config(hass, []) + client = await manager.async_create_client('test') + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, client.id) + assert refresh_token.user.id is user.id + assert refresh_token.client_id is client.id + + access_token = manager.async_create_access_token(refresh_token) + assert manager.async_get_access_token(access_token.token) is access_token + + with patch('homeassistant.auth.dt_util.utcnow', + return_value=dt_util.utcnow() + auth.ACCESS_TOKEN_EXPIRATION): + assert manager.async_get_access_token(access_token.token) is None + + # Even with unpatched time, it should have been removed from manager + assert manager.async_get_access_token(access_token.token) is None + + +async def test_get_or_create_client(hass): + """Test that get_or_create_client works.""" + 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_cannot_create_refresh_token_with_invalide_client_id(hass): + """Test that we cannot create refresh token with invalid client id.""" + manager = await auth.auth_manager_from_config(hass, []) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, 'bla') + + +async def test_cannot_create_refresh_token_with_invalide_user(hass): + """Test that we cannot create refresh token with invalid client id.""" + manager = await auth.auth_manager_from_config(hass, []) + client = await manager.async_create_client('test') + user = MockUser(id='invalid-user') + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, client.id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 84bd0771542..d7a7ec4b82b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1,13 +1,16 @@ """Test the config manager.""" import asyncio -from unittest.mock import MagicMock, patch, mock_open +from datetime import timedelta +from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, loader, data_entry_flow from homeassistant.setup import async_setup_component +from homeassistant.util import dt -from tests.common import MockModule, mock_coro, MockConfigEntry +from tests.common import ( + MockModule, mock_coro, MockConfigEntry, async_fire_time_changed) @pytest.fixture @@ -15,6 +18,7 @@ def manager(hass): """Fixture of a loaded config manager.""" manager = config_entries.ConfigEntries(hass, {}) manager._entries = [] + manager._store._async_ensure_stop_listener = lambda: None hass.config_entries = manager return manager @@ -148,10 +152,11 @@ def test_domains_gets_uniques(manager): assert manager.async_domains() == ['test', 'test2', 'test3'] -@asyncio.coroutine -def test_saving_and_loading(hass): +async def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" - loader.set_component(hass, 'test', MockModule('test')) + loader.set_component( + hass, 'test', + MockModule('test', async_setup_entry=lambda *args: mock_coro(True))) class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 @@ -166,7 +171,7 @@ def test_saving_and_loading(hass): ) with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from hass.config_entries.flow.async_init('test') + await hass.config_entries.flow.async_init('test') class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @@ -180,28 +185,18 @@ def test_saving_and_loading(hass): } ) - json_path = 'homeassistant.util.json.open' - with patch('homeassistant.config_entries.HANDLERS.get', - return_value=Test2Flow), \ - patch.object(config_entries, 'SAVE_DELAY', 0): - yield from hass.config_entries.flow.async_init('test') + return_value=Test2Flow): + await hass.config_entries.flow.async_init('test') - with patch(json_path, mock_open(), create=True) as mock_write: - # To trigger the call_later - yield from asyncio.sleep(0, loop=hass.loop) - # To execute the save - yield from hass.async_block_till_done() - - # Mock open calls are: open file, context enter, write, context leave - written = mock_write.mock_calls[2][1][0] + # To trigger the call_later + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + # To execute the save + await hass.async_block_till_done() # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) - - with patch('os.path.isfile', return_value=True), \ - patch(json_path, mock_open(read_data=written), create=True): - yield from manager.async_load() + await manager.async_load() # Ensure same order for orig, loaded in zip(hass.config_entries.async_entries(), @@ -304,3 +299,13 @@ async def test_discovery_notification_not_created(hass): await hass.async_block_till_done() state = hass.states.get('persistent_notification.config_entry_discovery') assert state is None + + +async def test_loading_default_config(hass): + """Test loading the default config.""" + manager = config_entries.ConfigEntries(hass, {}) + + with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): + await manager.async_load() + + assert len(manager.async_entries()) == 0 diff --git a/tests/test_loader.py b/tests/test_loader.py index c97e94a7ce1..d87201fb61b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -124,3 +124,13 @@ async def test_custom_component_name(hass): # Test custom components is mounted from custom_components.test_package import TEST assert TEST == 5 + + +async def test_log_warning_custom_component(hass, caplog): + """Test that we log a warning when loading a custom component.""" + loader.get_component(hass, 'test_standalone') + assert \ + 'You are using a custom component for test_standalone' in caplog.text + + loader.get_component(hass, 'light.test') + assert 'You are using a custom component for light.test' in caplog.text