mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +00:00
Merge remote-tracking branch 'upstream/dev' into igd
This commit is contained in:
commit
e73f31d829
19
.coveragerc
19
.coveragerc
@ -104,6 +104,9 @@ omit =
|
||||
homeassistant/components/fritzbox.py
|
||||
homeassistant/components/switch/fritzbox.py
|
||||
|
||||
homeassistant/components/ecovacs.py
|
||||
homeassistant/components/*/ecovacs.py
|
||||
|
||||
homeassistant/components/eufy.py
|
||||
homeassistant/components/*/eufy.py
|
||||
|
||||
@ -113,6 +116,12 @@ omit =
|
||||
homeassistant/components/google.py
|
||||
homeassistant/components/*/google.py
|
||||
|
||||
homeassistant/components/hangouts/__init__.py
|
||||
homeassistant/components/hangouts/const.py
|
||||
homeassistant/components/hangouts/hangouts_bot.py
|
||||
homeassistant/components/hangouts/hangups_utils.py
|
||||
homeassistant/components/*/hangouts.py
|
||||
|
||||
homeassistant/components/hdmi_cec.py
|
||||
homeassistant/components/*/hdmi_cec.py
|
||||
|
||||
@ -133,12 +142,13 @@ omit =
|
||||
|
||||
homeassistant/components/ihc/*
|
||||
homeassistant/components/*/ihc.py
|
||||
|
||||
homeassistant/components/insteon/*
|
||||
homeassistant/components/*/insteon.py
|
||||
|
||||
homeassistant/components/insteon_local.py
|
||||
homeassistant/components/*/insteon_local.py
|
||||
|
||||
homeassistant/components/insteon_plm/*
|
||||
homeassistant/components/*/insteon_plm.py
|
||||
|
||||
homeassistant/components/insteon_plm.py
|
||||
|
||||
homeassistant/components/ios.py
|
||||
homeassistant/components/*/ios.py
|
||||
@ -685,6 +695,7 @@ omit =
|
||||
homeassistant/components/sensor/netdata.py
|
||||
homeassistant/components/sensor/netdata_public.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
homeassistant/components/sensor/noaa_tides.py
|
||||
homeassistant/components/sensor/nsw_fuel_station.py
|
||||
homeassistant/components/sensor/nut.py
|
||||
homeassistant/components/sensor/nzbget.py
|
||||
|
@ -87,6 +87,8 @@ homeassistant/components/*/axis.py @kane610
|
||||
homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/ecovacs.py @OverloadUT
|
||||
homeassistant/components/*/ecovacs.py @OverloadUT
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
|
@ -60,14 +60,6 @@ loader module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
remote module
|
||||
---------------------------
|
||||
|
||||
.. automodule:: homeassistant.remote
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
@ -2,23 +2,30 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import List, Awaitable
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
import jwt
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import auth_store
|
||||
from .providers import auth_provider_from_config
|
||||
from . import auth_store, models
|
||||
from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule
|
||||
from .providers import auth_provider_from_config, AuthProvider, LoginFlow
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MfaModuleDict = Dict[str, MultiFactorAuthModule]
|
||||
_ProviderKey = Tuple[str, Optional[str]]
|
||||
_ProviderDict = Dict[_ProviderKey, AuthProvider]
|
||||
|
||||
|
||||
async def auth_manager_from_config(
|
||||
hass: HomeAssistant,
|
||||
provider_configs: List[dict]) -> Awaitable['AuthManager']:
|
||||
provider_configs: List[Dict[str, Any]],
|
||||
module_configs: List[Dict[str, Any]]) -> 'AuthManager':
|
||||
"""Initialize an auth manager from config."""
|
||||
store = auth_store.AuthStore(hass)
|
||||
if provider_configs:
|
||||
@ -26,9 +33,9 @@ async def auth_manager_from_config(
|
||||
*[auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs])
|
||||
else:
|
||||
providers = []
|
||||
providers = ()
|
||||
# So returned auth providers are in same order as config
|
||||
provider_hash = OrderedDict()
|
||||
provider_hash = OrderedDict() # type: _ProviderDict
|
||||
for provider in providers:
|
||||
if provider is None:
|
||||
continue
|
||||
@ -42,28 +49,53 @@ async def auth_manager_from_config(
|
||||
continue
|
||||
|
||||
provider_hash[key] = provider
|
||||
manager = AuthManager(hass, store, provider_hash)
|
||||
|
||||
if module_configs:
|
||||
modules = await asyncio.gather(
|
||||
*[auth_mfa_module_from_config(hass, config)
|
||||
for config in module_configs])
|
||||
else:
|
||||
modules = ()
|
||||
# So returned auth modules are in same order as config
|
||||
module_hash = OrderedDict() # type: _MfaModuleDict
|
||||
for module in modules:
|
||||
if module is None:
|
||||
continue
|
||||
|
||||
if module.id in module_hash:
|
||||
_LOGGER.error(
|
||||
'Found duplicate multi-factor module: %s. Please add unique '
|
||||
'IDs if you want to have the same module twice.', module.id)
|
||||
continue
|
||||
|
||||
module_hash[module.id] = module
|
||||
|
||||
manager = AuthManager(hass, store, provider_hash, module_hash)
|
||||
return manager
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage the authentication for Home Assistant."""
|
||||
|
||||
def __init__(self, hass, store, providers):
|
||||
def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore,
|
||||
providers: _ProviderDict, mfa_modules: _MfaModuleDict) \
|
||||
-> None:
|
||||
"""Initialize the auth manager."""
|
||||
self.hass = hass
|
||||
self._store = store
|
||||
self._providers = providers
|
||||
self._mfa_modules = mfa_modules
|
||||
self.login_flow = data_entry_flow.FlowManager(
|
||||
hass, self._async_create_login_flow,
|
||||
self._async_finish_login_flow)
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
def active(self) -> bool:
|
||||
"""Return if any auth providers are registered."""
|
||||
return bool(self._providers)
|
||||
|
||||
@property
|
||||
def support_legacy(self):
|
||||
def support_legacy(self) -> bool:
|
||||
"""
|
||||
Return if legacy_api_password auth providers are registered.
|
||||
|
||||
@ -75,19 +107,39 @@ class AuthManager:
|
||||
return False
|
||||
|
||||
@property
|
||||
def auth_providers(self):
|
||||
def auth_providers(self) -> List[AuthProvider]:
|
||||
"""Return a list of available auth providers."""
|
||||
return list(self._providers.values())
|
||||
|
||||
async def async_get_users(self):
|
||||
@property
|
||||
def auth_mfa_modules(self) -> List[MultiFactorAuthModule]:
|
||||
"""Return a list of available auth modules."""
|
||||
return list(self._mfa_modules.values())
|
||||
|
||||
def get_auth_mfa_module(self, module_id: str) \
|
||||
-> Optional[MultiFactorAuthModule]:
|
||||
"""Return an multi-factor auth module, None if not found."""
|
||||
return self._mfa_modules.get(module_id)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
return await self._store.async_get_users()
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
async def async_get_user(self, user_id: str) -> Optional[models.User]:
|
||||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_id)
|
||||
|
||||
async def async_create_system_user(self, name):
|
||||
async def async_get_user_by_credentials(
|
||||
self, credentials: models.Credentials) -> Optional[models.User]:
|
||||
"""Get a user by credential, return None if not found."""
|
||||
for user in await self.async_get_users():
|
||||
for creds in user.credentials:
|
||||
if creds.id == credentials.id:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_system_user(self, name: str) -> models.User:
|
||||
"""Create a system user."""
|
||||
return await self._store.async_create_user(
|
||||
name=name,
|
||||
@ -95,27 +147,27 @@ class AuthManager:
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
async def async_create_user(self, name):
|
||||
async def async_create_user(self, name: str) -> models.User:
|
||||
"""Create a user."""
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'is_active': True,
|
||||
}
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if await self._user_should_be_owner():
|
||||
kwargs['is_owner'] = True
|
||||
|
||||
return await self._store.async_create_user(**kwargs)
|
||||
|
||||
async def async_get_or_create_user(self, credentials):
|
||||
async def async_get_or_create_user(self, credentials: models.Credentials) \
|
||||
-> models.User:
|
||||
"""Get or create a user."""
|
||||
if not credentials.is_new:
|
||||
for user in await self._store.async_get_users():
|
||||
for creds in user.credentials:
|
||||
if creds.id == credentials.id:
|
||||
return user
|
||||
|
||||
raise ValueError('Unable to find the user.')
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is None:
|
||||
raise ValueError('Unable to find the user.')
|
||||
else:
|
||||
return user
|
||||
|
||||
auth_provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
@ -127,15 +179,16 @@ class AuthManager:
|
||||
|
||||
return await self._store.async_create_user(
|
||||
credentials=credentials,
|
||||
name=info.get('name'),
|
||||
is_active=info.get('is_active', False)
|
||||
name=info.name,
|
||||
is_active=info.is_active,
|
||||
)
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Link credentials to an existing user."""
|
||||
await self._store.async_link_user(user, credentials)
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
async def async_remove_user(self, user: models.User) -> None:
|
||||
"""Remove a user."""
|
||||
tasks = [
|
||||
self.async_remove_credentials(credentials)
|
||||
@ -147,27 +200,75 @@ class AuthManager:
|
||||
|
||||
await self._store.async_remove_user(user)
|
||||
|
||||
async def async_activate_user(self, user):
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
await self._store.async_activate_user(user)
|
||||
|
||||
async def async_deactivate_user(self, user):
|
||||
async def async_deactivate_user(self, user: models.User) -> None:
|
||||
"""Deactivate a user."""
|
||||
if user.is_owner:
|
||||
raise ValueError('Unable to deactive the owner')
|
||||
await self._store.async_deactivate_user(user)
|
||||
|
||||
async def async_remove_credentials(self, credentials):
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
if (provider is not None and
|
||||
hasattr(provider, 'async_will_remove_credentials')):
|
||||
await provider.async_will_remove_credentials(credentials)
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
await provider.async_will_remove_credentials( # type: ignore
|
||||
credentials)
|
||||
|
||||
await self._store.async_remove_credentials(credentials)
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id=None):
|
||||
async def async_enable_user_mfa(self, user: models.User,
|
||||
mfa_module_id: str, data: Any) -> None:
|
||||
"""Enable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError('System generated users cannot enable '
|
||||
'multi-factor auth module.')
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError('Unable find multi-factor auth module: {}'
|
||||
.format(mfa_module_id))
|
||||
|
||||
if module.setup_schema is not None:
|
||||
try:
|
||||
# pylint: disable=not-callable
|
||||
data = module.setup_schema(data)
|
||||
except vol.Invalid as err:
|
||||
raise ValueError('Data does not match schema: {}'.format(err))
|
||||
|
||||
await module.async_setup_user(user.id, data)
|
||||
|
||||
async def async_disable_user_mfa(self, user: models.User,
|
||||
mfa_module_id: str) -> None:
|
||||
"""Disable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError('System generated users cannot disable '
|
||||
'multi-factor auth module.')
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError('Unable find multi-factor auth module: {}'
|
||||
.format(mfa_module_id))
|
||||
|
||||
await module.async_depose_user(user.id)
|
||||
|
||||
async def async_get_enabled_mfa(self, user: models.User) -> List[str]:
|
||||
"""List enabled mfa modules for user."""
|
||||
module_ids = []
|
||||
for module_id, module in self._mfa_modules.items():
|
||||
if await module.async_is_user_setup(user.id):
|
||||
module_ids.append(module_id)
|
||||
return module_ids
|
||||
|
||||
async def async_create_refresh_token(self, user: models.User,
|
||||
client_id: Optional[str] = None) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
raise ValueError('User is not active')
|
||||
@ -182,16 +283,25 @@ class AuthManager:
|
||||
|
||||
return await self._store.async_create_refresh_token(user, client_id)
|
||||
|
||||
async def async_get_refresh_token(self, token_id):
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
return await self._store.async_get_refresh_token(token_id)
|
||||
|
||||
async def async_get_refresh_token_by_token(self, token):
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token_by_token(token)
|
||||
|
||||
async def async_remove_refresh_token(self,
|
||||
refresh_token: models.RefreshToken) \
|
||||
-> None:
|
||||
"""Delete a refresh token."""
|
||||
await self._store.async_remove_refresh_token(refresh_token)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(self, refresh_token):
|
||||
def async_create_access_token(self,
|
||||
refresh_token: models.RefreshToken) -> str:
|
||||
"""Create a new access token."""
|
||||
# pylint: disable=no-self-use
|
||||
return jwt.encode({
|
||||
@ -200,15 +310,16 @@ class AuthManager:
|
||||
'exp': dt_util.utcnow() + refresh_token.access_token_expiration,
|
||||
}, refresh_token.jwt_key, algorithm='HS256').decode()
|
||||
|
||||
async def async_validate_access_token(self, token):
|
||||
"""Return if an access token is valid."""
|
||||
async def async_validate_access_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Return refresh token if an access token is valid."""
|
||||
try:
|
||||
unverif_claims = jwt.decode(token, verify=False)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
refresh_token = await self.async_get_refresh_token(
|
||||
unverif_claims.get('iss'))
|
||||
cast(str, unverif_claims.get('iss')))
|
||||
|
||||
if refresh_token is None:
|
||||
jwt_key = ''
|
||||
@ -228,34 +339,63 @@ class AuthManager:
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
if not refresh_token.user.is_active:
|
||||
if refresh_token is None or not refresh_token.user.is_active:
|
||||
return None
|
||||
|
||||
return refresh_token
|
||||
|
||||
async def _async_create_login_flow(self, handler, *, context, data):
|
||||
async def _async_create_login_flow(
|
||||
self, handler: _ProviderKey, *, context: Optional[Dict],
|
||||
data: Optional[Any]) -> data_entry_flow.FlowHandler:
|
||||
"""Create a login flow."""
|
||||
auth_provider = self._providers[handler]
|
||||
|
||||
return await auth_provider.async_credential_flow(context)
|
||||
return await auth_provider.async_login_flow(context)
|
||||
|
||||
async def _async_finish_login_flow(self, context, result):
|
||||
"""Result of a credential login flow."""
|
||||
async def _async_finish_login_flow(
|
||||
self, flow: LoginFlow, result: Dict[str, Any]) \
|
||||
-> Dict[str, Any]:
|
||||
"""Return a user as result of login flow."""
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return None
|
||||
return result
|
||||
|
||||
# we got final result
|
||||
if isinstance(result['data'], models.User):
|
||||
result['result'] = result['data']
|
||||
return result
|
||||
|
||||
auth_provider = self._providers[result['handler']]
|
||||
return await auth_provider.async_get_or_create_credentials(
|
||||
credentials = await auth_provider.async_get_or_create_credentials(
|
||||
result['data'])
|
||||
|
||||
if flow.context is not None and flow.context.get('credential_only'):
|
||||
result['result'] = credentials
|
||||
return result
|
||||
|
||||
# multi-factor module cannot enabled for new credential
|
||||
# which has not linked to a user yet
|
||||
if auth_provider.support_mfa and not credentials.is_new:
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is not None:
|
||||
modules = await self.async_get_enabled_mfa(user)
|
||||
|
||||
if modules:
|
||||
flow.user = user
|
||||
flow.available_mfa_modules = modules
|
||||
return await flow.async_step_select_mfa_module()
|
||||
|
||||
result['result'] = await self.async_get_or_create_user(credentials)
|
||||
return result
|
||||
|
||||
@callback
|
||||
def _async_get_auth_provider(self, credentials):
|
||||
"""Helper to get auth provider from a set of credentials."""
|
||||
def _async_get_auth_provider(
|
||||
self, credentials: models.Credentials) -> Optional[AuthProvider]:
|
||||
"""Get auth provider from a set of credentials."""
|
||||
auth_provider_key = (credentials.auth_provider_type,
|
||||
credentials.auth_provider_id)
|
||||
return self._providers.get(auth_provider_key)
|
||||
|
||||
async def _user_should_be_owner(self):
|
||||
async def _user_should_be_owner(self) -> bool:
|
||||
"""Determine if user should be owner.
|
||||
|
||||
A user should be an owner if it is the first non-system user that is
|
||||
|
@ -1,8 +1,11 @@
|
||||
"""Storage for auth models."""
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional # noqa: F401
|
||||
import hmac
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import models
|
||||
@ -20,35 +23,41 @@ class AuthStore:
|
||||
called that needs it.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self._users = None
|
||||
self._users = None # type: Optional[Dict[str, models.User]]
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
|
||||
async def async_get_users(self):
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
if self._users is None:
|
||||
await self.async_load()
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return list(self._users.values())
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
async def async_get_user(self, user_id: str) -> Optional[models.User]:
|
||||
"""Retrieve a user by id."""
|
||||
if self._users is None:
|
||||
await self.async_load()
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return self._users.get(user_id)
|
||||
|
||||
async def async_create_user(self, name, is_owner=None, is_active=None,
|
||||
system_generated=None, credentials=None):
|
||||
async def async_create_user(
|
||||
self, name: Optional[str], is_owner: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
system_generated: Optional[bool] = None,
|
||||
credentials: Optional[models.Credentials] = None) -> models.User:
|
||||
"""Create a new user."""
|
||||
if self._users is None:
|
||||
await self.async_load()
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
kwargs = {
|
||||
'name': name
|
||||
}
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if is_owner is not None:
|
||||
kwargs['is_owner'] = is_owner
|
||||
@ -64,36 +73,46 @@ class AuthStore:
|
||||
self._users[new_user.id] = new_user
|
||||
|
||||
if credentials is None:
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
return new_user
|
||||
|
||||
# Saving is done inside the link.
|
||||
await self.async_link_user(new_user, credentials)
|
||||
return new_user
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Add credentials to an existing user."""
|
||||
user.credentials.append(credentials)
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
credentials.is_new = False
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
async def async_remove_user(self, user: models.User) -> None:
|
||||
"""Remove a user."""
|
||||
self._users.pop(user.id)
|
||||
await self.async_save()
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
async def async_activate_user(self, user):
|
||||
self._users.pop(user.id)
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = True
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_deactivate_user(self, user):
|
||||
async def async_deactivate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = False
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_remove_credentials(self, credentials):
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
found = None
|
||||
|
||||
@ -106,19 +125,35 @@ class AuthStore:
|
||||
user.credentials.pop(found)
|
||||
break
|
||||
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id=None):
|
||||
async def async_create_refresh_token(
|
||||
self, user: models.User, client_id: Optional[str] = None) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
refresh_token = models.RefreshToken(user=user, client_id=client_id)
|
||||
user.refresh_tokens[refresh_token.id] = refresh_token
|
||||
await self.async_save()
|
||||
self._async_schedule_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_get_refresh_token(self, token_id):
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken) -> None:
|
||||
"""Remove a refresh token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
if user.refresh_tokens.pop(refresh_token.id, None):
|
||||
self._async_schedule_save()
|
||||
break
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
if self._users is None:
|
||||
await self.async_load()
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
refresh_token = user.refresh_tokens.get(token_id)
|
||||
@ -127,10 +162,12 @@ class AuthStore:
|
||||
|
||||
return None
|
||||
|
||||
async def async_get_refresh_token_by_token(self, token):
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
if self._users is None:
|
||||
await self.async_load()
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
found = None
|
||||
|
||||
@ -141,7 +178,7 @@ class AuthStore:
|
||||
|
||||
return found
|
||||
|
||||
async def async_load(self):
|
||||
async def _async_load(self) -> None:
|
||||
"""Load the users."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
@ -150,7 +187,7 @@ class AuthStore:
|
||||
if self._users is not None:
|
||||
return
|
||||
|
||||
users = OrderedDict()
|
||||
users = OrderedDict() # type: Dict[str, models.User]
|
||||
|
||||
if data is None:
|
||||
self._users = users
|
||||
@ -173,11 +210,17 @@ class AuthStore:
|
||||
if 'jwt_key' not in rt_dict:
|
||||
continue
|
||||
|
||||
created_at = dt_util.parse_datetime(rt_dict['created_at'])
|
||||
if created_at is None:
|
||||
getLogger(__name__).error(
|
||||
'Ignoring refresh token %(id)s with invalid created_at '
|
||||
'%(created_at)s for user_id %(user_id)s', rt_dict)
|
||||
continue
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict['id'],
|
||||
user=users[rt_dict['user_id']],
|
||||
client_id=rt_dict['client_id'],
|
||||
created_at=dt_util.parse_datetime(rt_dict['created_at']),
|
||||
created_at=created_at,
|
||||
access_token_expiration=timedelta(
|
||||
seconds=rt_dict['access_token_expiration']),
|
||||
token=rt_dict['token'],
|
||||
@ -187,8 +230,19 @@ class AuthStore:
|
||||
|
||||
self._users = users
|
||||
|
||||
async def async_save(self):
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
"""Save users."""
|
||||
if self._users is None:
|
||||
return
|
||||
|
||||
self._store.async_delay_save(self._data_to_save, 1)
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> Dict:
|
||||
"""Return the data to store."""
|
||||
assert self._users is not None
|
||||
|
||||
users = [
|
||||
{
|
||||
'id': user.id,
|
||||
@ -227,10 +281,8 @@ class AuthStore:
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
]
|
||||
|
||||
data = {
|
||||
return {
|
||||
'users': users,
|
||||
'credentials': credentials,
|
||||
'refresh_tokens': refresh_tokens,
|
||||
}
|
||||
|
||||
await self._store.async_save(data, delay=1)
|
||||
|
141
homeassistant/auth/mfa_modules/__init__.py
Normal file
141
homeassistant/auth/mfa_modules/__init__.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Plugable auth modules for Home Assistant."""
|
||||
from datetime import timedelta
|
||||
import importlib
|
||||
import logging
|
||||
import types
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import requirements
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULES = Registry()
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two mfa auth module for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
|
||||
DATA_REQS = 'mfa_auth_module_reqs_processed'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiFactorAuthModule:
|
||||
"""Multi-factor Auth Module of validation function."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth module'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth module."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> str: # pylint: disable=invalid-name
|
||||
"""Return id of the auth module.
|
||||
|
||||
Default is same as type
|
||||
"""
|
||||
return self.config.get(CONF_ID, self.type)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Return type of the module."""
|
||||
return self.config[CONF_TYPE] # type: ignore
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the auth module."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Return a voluptuous schema to define mfa auth module's input."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def setup_schema(self) -> Optional[vol.Schema]:
|
||||
"""Return a vol schema to validate mfa auth module's setup input.
|
||||
|
||||
Optional
|
||||
"""
|
||||
return None
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> None:
|
||||
"""Set up user for mfa auth module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_validation(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def auth_mfa_module_from_config(
|
||||
hass: HomeAssistant, config: Dict[str, Any]) \
|
||||
-> Optional[MultiFactorAuthModule]:
|
||||
"""Initialize an auth module from a config."""
|
||||
module_name = config[CONF_TYPE]
|
||||
module = await _load_mfa_module(hass, module_name)
|
||||
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for multi-factor module %s: %s',
|
||||
module_name, humanize_error(config, err))
|
||||
return None
|
||||
|
||||
return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore
|
||||
|
||||
|
||||
async def _load_mfa_module(hass: HomeAssistant, module_name: str) \
|
||||
-> Optional[types.ModuleType]:
|
||||
"""Load an mfa auth module."""
|
||||
module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError:
|
||||
_LOGGER.warning('Unable to find %s', module_path)
|
||||
return None
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
if processed and module_name in processed:
|
||||
return module
|
||||
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, module_path, module.REQUIREMENTS) # type: ignore
|
||||
|
||||
if not req_success:
|
||||
return None
|
||||
|
||||
processed.add(module_name)
|
||||
return module
|
82
homeassistant/auth/mfa_modules/insecure_example.py
Normal file
82
homeassistant/auth/mfa_modules/insecure_example.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Example auth module."""
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
|
||||
vol.Required('data'): [vol.Schema({
|
||||
vol.Required('user_id'): str,
|
||||
vol.Required('pin'): str,
|
||||
})]
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register('insecure_example')
|
||||
class InsecureExampleModule(MultiFactorAuthModule):
|
||||
"""Example auth module validate pin."""
|
||||
|
||||
DEFAULT_TITLE = 'Insecure Personal Identify Number'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._data = config['data']
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({'pin': str})
|
||||
|
||||
@property
|
||||
def setup_schema(self) -> Optional[vol.Schema]:
|
||||
"""Validate async_setup_user input data."""
|
||||
return vol.Schema({'pin': str})
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> None:
|
||||
"""Set up user to use mfa module."""
|
||||
# data shall has been validate in caller
|
||||
pin = setup_data['pin']
|
||||
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
# already setup, override
|
||||
data['pin'] = pin
|
||||
return
|
||||
|
||||
self._data.append({'user_id': user_id, 'pin': pin})
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
found = None
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
found = data
|
||||
break
|
||||
if found:
|
||||
self._data.remove(found)
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_validation(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
# user_input has been validate in caller
|
||||
if data['pin'] == user_input['pin']:
|
||||
return True
|
||||
|
||||
return False
|
@ -1,5 +1,6 @@
|
||||
"""Auth models."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, NamedTuple, Optional # noqa: F401
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
@ -14,17 +15,21 @@ from .util import generate_secret
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
name = attr.ib(type=str)
|
||||
name = attr.ib(type=str) # type: Optional[str]
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
system_generated = attr.ib(type=bool, default=False)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False)
|
||||
credentials = attr.ib(
|
||||
type=list, default=attr.Factory(list), cmp=False
|
||||
) # type: List[Credentials]
|
||||
|
||||
# Tokens associated with a user.
|
||||
refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False)
|
||||
refresh_tokens = attr.ib(
|
||||
type=dict, default=attr.Factory(dict), cmp=False
|
||||
) # type: Dict[str, RefreshToken]
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
@ -32,7 +37,7 @@ class RefreshToken:
|
||||
"""RefreshToken for a user to grant new access tokens."""
|
||||
|
||||
user = attr.ib(type=User)
|
||||
client_id = attr.ib(type=str)
|
||||
client_id = attr.ib(type=str) # type: Optional[str]
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
access_token_expiration = attr.ib(type=timedelta,
|
||||
@ -48,10 +53,14 @@ class Credentials:
|
||||
"""Credentials for a user on an auth provider."""
|
||||
|
||||
auth_provider_type = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=str) # type: Optional[str]
|
||||
|
||||
# Allow the auth provider to store data to represent their auth.
|
||||
data = attr.ib(type=dict)
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_new = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
UserMeta = NamedTuple("UserMeta",
|
||||
[('name', Optional[str]), ('is_active', bool)])
|
||||
|
@ -1,16 +1,21 @@
|
||||
"""Auth providers for Home Assistant."""
|
||||
import importlib
|
||||
import logging
|
||||
import types
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import requirements
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from homeassistant.auth.models import Credentials
|
||||
from ..auth_store import AuthStore
|
||||
from ..models import Credentials, User, UserMeta # noqa: F401
|
||||
from ..mfa_modules import SESSION_EXPIRATION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DATA_REQS = 'auth_prov_reqs_processed'
|
||||
@ -25,7 +30,87 @@ AUTH_PROVIDER_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def auth_provider_from_config(hass, store, config):
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[str]: # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
"""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Return type of the provider."""
|
||||
return self.config[CONF_TYPE] # type: ignore
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Return whether multi-factor auth supported by the auth provider."""
|
||||
return True
|
||||
|
||||
async def async_credentials(self) -> List[Credentials]:
|
||||
"""Return all credentials of this provider."""
|
||||
users = await self.store.async_get_users()
|
||||
return [
|
||||
credentials
|
||||
for user in users
|
||||
for credentials in user.credentials
|
||||
if (credentials.auth_provider_type == self.type and
|
||||
credentials.auth_provider_id == self.id)
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data: Dict[str, str]) -> Credentials:
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow':
|
||||
"""Return the data flow for logging in with auth provider.
|
||||
|
||||
Auth provider should extend LoginFlow and return an instance.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def auth_provider_from_config(
|
||||
hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> Optional[AuthProvider]:
|
||||
"""Initialize an auth provider from a config."""
|
||||
provider_name = config[CONF_TYPE]
|
||||
module = await load_auth_provider_module(hass, provider_name)
|
||||
@ -34,16 +119,17 @@ async def auth_provider_from_config(hass, store, config):
|
||||
return None
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config)
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for auth provider %s: %s',
|
||||
provider_name, humanize_error(config, err))
|
||||
return None
|
||||
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config)
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore
|
||||
|
||||
|
||||
async def load_auth_provider_module(hass, provider):
|
||||
async def load_auth_provider_module(
|
||||
hass: HomeAssistant, provider: str) -> Optional[types.ModuleType]:
|
||||
"""Load an auth provider."""
|
||||
try:
|
||||
module = importlib.import_module(
|
||||
@ -62,8 +148,10 @@ async def load_auth_provider_module(hass, provider):
|
||||
elif provider in processed:
|
||||
return module
|
||||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
reqs = module.REQUIREMENTS # type: ignore
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, 'auth provider {}'.format(provider), module.REQUIREMENTS)
|
||||
hass, 'auth provider {}'.format(provider), reqs)
|
||||
|
||||
if not req_success:
|
||||
return None
|
||||
@ -72,72 +160,88 @@ async def load_auth_provider_module(hass, provider):
|
||||
return module
|
||||
|
||||
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
def __init__(self, auth_provider: AuthProvider) -> None:
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
self._auth_module_id = None # type: Optional[str]
|
||||
self._auth_manager = auth_provider.hass.auth # type: ignore
|
||||
self.available_mfa_modules = [] # type: List
|
||||
self.created_at = dt_util.utcnow()
|
||||
self.user = None # type: Optional[User]
|
||||
|
||||
def __init__(self, hass, store, config):
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the first step of login flow.
|
||||
|
||||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
Return self.async_show_form(step_id='init') if user_input == None.
|
||||
Return await self.async_finish(flow_result) if login init step pass.
|
||||
"""
|
||||
return self.config.get(CONF_ID)
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Return type of the provider."""
|
||||
return self.config[CONF_TYPE]
|
||||
async def async_step_select_mfa_module(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of select mfa module."""
|
||||
errors = {}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
if user_input is not None:
|
||||
auth_module = user_input.get('multi_factor_auth_module')
|
||||
if auth_module in self.available_mfa_modules:
|
||||
self._auth_module_id = auth_module
|
||||
return await self.async_step_mfa()
|
||||
errors['base'] = 'invalid_auth_module'
|
||||
|
||||
async def async_credentials(self):
|
||||
"""Return all credentials of this provider."""
|
||||
users = await self.store.async_get_users()
|
||||
return [
|
||||
credentials
|
||||
for user in users
|
||||
for credentials in user.credentials
|
||||
if (credentials.auth_provider_type == self.type and
|
||||
credentials.auth_provider_id == self.id)
|
||||
]
|
||||
if len(self.available_mfa_modules) == 1:
|
||||
self._auth_module_id = self.available_mfa_modules[0]
|
||||
return await self.async_step_mfa()
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data):
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
return self.async_show_form(
|
||||
step_id='select_mfa_module',
|
||||
data_schema=vol.Schema({
|
||||
'multi_factor_auth_module': vol.In(self.available_mfa_modules)
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
async def async_step_mfa(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of mfa validation."""
|
||||
errors = {}
|
||||
|
||||
async def async_credential_flow(self, context):
|
||||
"""Return the data flow for logging in with auth provider."""
|
||||
raise NotImplementedError
|
||||
auth_module = self._auth_manager.get_auth_mfa_module(
|
||||
self._auth_module_id)
|
||||
if auth_module is None:
|
||||
# Given an invalid input to async_step_select_mfa_module
|
||||
# will show invalid_auth_module error
|
||||
return await self.async_step_select_mfa_module(user_input={})
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
if user_input is not None:
|
||||
expires = self.created_at + SESSION_EXPIRATION
|
||||
if dt_util.utcnow() > expires:
|
||||
errors['base'] = 'login_expired'
|
||||
else:
|
||||
result = await auth_module.async_validation(
|
||||
self.user.id, user_input) # type: ignore
|
||||
if not result:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
"""Return extra user metadata for credentials.
|
||||
if not errors:
|
||||
return await self.async_finish(self.user)
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
return self.async_show_form(
|
||||
step_id='mfa',
|
||||
data_schema=auth_module.input_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
Values to populate:
|
||||
- name: string
|
||||
- is_active: boolean
|
||||
"""
|
||||
return {}
|
||||
async def async_finish(self, flow_result: Any) -> Dict:
|
||||
"""Handle the pass of login flow."""
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=flow_result
|
||||
)
|
||||
|
@ -3,24 +3,24 @@ import base64
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Dict # noqa: F401 pylint: disable=unused-import
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from homeassistant.auth.util import generate_secret
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
from ..util import generate_secret
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth_provider.homeassistant'
|
||||
|
||||
|
||||
def _disallow_id(conf):
|
||||
def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Disallow ID in config."""
|
||||
if CONF_ID in conf:
|
||||
raise vol.Invalid(
|
||||
@ -46,13 +46,13 @@ class InvalidUser(HomeAssistantError):
|
||||
class Data:
|
||||
"""Hold the user data."""
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._data = None
|
||||
self._data = None # type: Optional[Dict[str, Any]]
|
||||
|
||||
async def async_load(self):
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
@ -65,9 +65,9 @@ class Data:
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
def users(self) -> List[Dict[str, str]]:
|
||||
"""Return users."""
|
||||
return self._data['users']
|
||||
return self._data['users'] # type: ignore
|
||||
|
||||
def validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password.
|
||||
@ -79,7 +79,7 @@ class Data:
|
||||
found = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for user in self._data['users']:
|
||||
for user in self.users:
|
||||
if username == user['username']:
|
||||
found = user
|
||||
|
||||
@ -94,8 +94,8 @@ class Data:
|
||||
|
||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||
"""Encode a password."""
|
||||
hashed = hashlib.pbkdf2_hmac(
|
||||
'sha512', password.encode(), self._data['salt'].encode(), 100000)
|
||||
salt = self._data['salt'].encode() # type: ignore
|
||||
hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000)
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed)
|
||||
return hashed
|
||||
@ -137,7 +137,7 @@ class Data:
|
||||
else:
|
||||
raise InvalidUser
|
||||
|
||||
async def async_save(self):
|
||||
async def async_save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
@ -150,7 +150,7 @@ class HassAuthProvider(AuthProvider):
|
||||
|
||||
data = None
|
||||
|
||||
async def async_initialize(self):
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
if self.data is not None:
|
||||
return
|
||||
@ -158,19 +158,22 @@ class HassAuthProvider(AuthProvider):
|
||||
self.data = Data(self.hass)
|
||||
await self.data.async_load()
|
||||
|
||||
async def async_credential_flow(self, context):
|
||||
async def async_login_flow(
|
||||
self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
return HassLoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username: str, password: str):
|
||||
"""Helper to validate a username and password."""
|
||||
async def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self.data.validate_login, username, password)
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
@ -183,17 +186,17 @@ class HassAuthProvider(AuthProvider):
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Get extra info for this credential."""
|
||||
return {
|
||||
'name': credentials.data['username'],
|
||||
'is_active': True,
|
||||
}
|
||||
return UserMeta(name=credentials.data['username'], is_active=True)
|
||||
|
||||
async def async_will_remove_credentials(self, credentials):
|
||||
async def async_will_remove_credentials(
|
||||
self, credentials: Credentials) -> None:
|
||||
"""When credentials get removed, also remove the auth."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
try:
|
||||
self.data.async_remove_auth(credentials.data['username'])
|
||||
@ -203,29 +206,26 @@ class HassAuthProvider(AuthProvider):
|
||||
pass
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
class HassLoginFlow(LoginFlow):
|
||||
"""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):
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
await cast(HassAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuth:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
|
@ -1,14 +1,15 @@
|
||||
"""Example auth provider."""
|
||||
from collections import OrderedDict
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
@ -31,13 +32,13 @@ class InvalidAuthError(HomeAssistantError):
|
||||
class ExampleAuthProvider(AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
async def async_credential_flow(self, context):
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
return ExampleLoginFlow(self)
|
||||
|
||||
@callback
|
||||
def async_validate_login(self, username, password):
|
||||
"""Helper to validate a username and password."""
|
||||
def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
user = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
@ -56,7 +57,8 @@ class ExampleAuthProvider(AuthProvider):
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
@ -69,49 +71,45 @@ class ExampleAuthProvider(AuthProvider):
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
username = credentials.data['username']
|
||||
info = {
|
||||
'is_active': True,
|
||||
}
|
||||
name = None
|
||||
|
||||
for user in self.config['users']:
|
||||
if user['username'] == username:
|
||||
info['name'] = user.get('name')
|
||||
name = user.get('name')
|
||||
break
|
||||
|
||||
return info
|
||||
return UserMeta(name=name, is_active=True)
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
class ExampleLoginFlow(LoginFlow):
|
||||
"""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):
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
cast(ExampleAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = OrderedDict()
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
|
@ -3,16 +3,17 @@ Support Legacy API password auth provider.
|
||||
|
||||
It will be removed when auth system production ready
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
@ -36,25 +37,21 @@ class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||
|
||||
DEFAULT_TITLE = 'Legacy API Password'
|
||||
|
||||
async def async_credential_flow(self, context):
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
return LegacyLoginFlow(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')
|
||||
def async_validate_login(self, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
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'),
|
||||
if not hmac.compare_digest(hass_http.api_password.encode('utf-8'),
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Return LEGACY_USER always."""
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == LEGACY_USER:
|
||||
@ -64,47 +61,43 @@ class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||
'username': LEGACY_USER
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""
|
||||
Set name as LEGACY_USER always.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
return {
|
||||
'name': LEGACY_USER,
|
||||
'is_active': True,
|
||||
}
|
||||
return UserMeta(name=LEGACY_USER, is_active=True)
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
class LegacyLoginFlow(LoginFlow):
|
||||
"""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):
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
hass_http = getattr(self.hass, 'http', None)
|
||||
if hass_http is None or not hass_http.api_password:
|
||||
return self.async_abort(
|
||||
reason='no_api_password_set'
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
self._auth_provider.async_validate_login(
|
||||
user_input['password'])
|
||||
cast(LegacyApiPasswordAuthProvider, 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 await self.async_finish({})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
data_schema=vol.Schema({'password': str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
@ -3,12 +3,16 @@
|
||||
It shows list of users if access from trusted network.
|
||||
Abort login flow if not access from trusted network.
|
||||
"""
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
@ -31,16 +35,24 @@ class TrustedNetworksAuthProvider(AuthProvider):
|
||||
|
||||
DEFAULT_TITLE = 'Trusted Networks'
|
||||
|
||||
async def async_credential_flow(self, context):
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Trusted Networks auth provider does not support MFA."""
|
||||
return False
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
assert context is not None
|
||||
users = await self.store.async_get_users()
|
||||
available_users = {user.id: user.name
|
||||
for user in users
|
||||
if not user.system_generated and user.is_active}
|
||||
|
||||
return LoginFlow(self, context.get('ip_address'), available_users)
|
||||
return TrustedNetworksLoginFlow(
|
||||
self, cast(str, context.get('ip_address')), available_users)
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
user_id = flow_result['user']
|
||||
|
||||
@ -59,7 +71,8 @@ class TrustedNetworksAuthProvider(AuthProvider):
|
||||
# We only allow login as exist user
|
||||
raise InvalidUserError
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Trusted network auth provider should never create new user.
|
||||
@ -67,35 +80,41 @@ class TrustedNetworksAuthProvider(AuthProvider):
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def async_validate_access(self, ip_address):
|
||||
def async_validate_access(self, ip_address: str) -> None:
|
||||
"""Make sure the access from trusted networks.
|
||||
|
||||
Raise InvalidAuthError if not.
|
||||
Raise InvalidAuthError if trusted_networks is not config
|
||||
Raise InvalidAuthError if trusted_networks is not configured.
|
||||
"""
|
||||
if (not hasattr(self.hass, 'http') or
|
||||
not self.hass.http or not self.hass.http.trusted_networks):
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
if not hass_http or not hass_http.trusted_networks:
|
||||
raise InvalidAuthError('trusted_networks is not configured')
|
||||
|
||||
if not any(ip_address in trusted_network for trusted_network
|
||||
in self.hass.http.trusted_networks):
|
||||
in hass_http.trusted_networks):
|
||||
raise InvalidAuthError('Not in trusted_networks')
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
class TrustedNetworksLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider, ip_address, available_users):
|
||||
def __init__(self, auth_provider: TrustedNetworksAuthProvider,
|
||||
ip_address: str, available_users: Dict[str, Optional[str]]) \
|
||||
-> None:
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
super().__init__(auth_provider)
|
||||
self._available_users = available_users
|
||||
self._ip_address = ip_address
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
try:
|
||||
self._auth_provider.async_validate_access(self._ip_address)
|
||||
cast(TrustedNetworksAuthProvider, self._auth_provider)\
|
||||
.async_validate_access(self._ip_address)
|
||||
|
||||
except InvalidAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
@ -111,10 +130,7 @@ class LoginFlow(data_entry_flow.FlowHandler):
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = {'user': vol.In(self._available_users)}
|
||||
|
||||
|
@ -87,9 +87,11 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
log_no_color)
|
||||
|
||||
core_config = config.get(core.DOMAIN, {})
|
||||
has_api_password = bool((config.get('http') or {}).get('api_password'))
|
||||
|
||||
try:
|
||||
await conf_util.async_process_ha_core_config(hass, core_config)
|
||||
await conf_util.async_process_ha_core_config(
|
||||
hass, core_config, has_api_password)
|
||||
except vol.Invalid as ex:
|
||||
conf_util.async_log_exception(ex, 'homeassistant', core_config, hass)
|
||||
return None
|
||||
@ -307,7 +309,7 @@ def async_enable_logging(hass: core.HomeAssistant,
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unable to setup error log %s (access denied)", err_log_path)
|
||||
"Unable to set up error log %s (access denied)", err_log_path)
|
||||
|
||||
|
||||
async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
|
@ -141,7 +141,7 @@ def async_setup(hass, config):
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Setup a config entry."""
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
|
@ -1,32 +1,31 @@
|
||||
"""
|
||||
Support for HomematicIP alarm control panel.
|
||||
Support for HomematicIP Cloud alarm control panel.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanel
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HMIPC_HAPID, HomematicipGenericDevice)
|
||||
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_TRIGGERED)
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanel
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
|
||||
HMIPC_HAPID)
|
||||
|
||||
|
||||
DEPENDENCIES = ['homematicip_cloud']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['homematicip_cloud']
|
||||
|
||||
HMIP_ZONE_AWAY = 'EXTERNAL'
|
||||
HMIP_ZONE_HOME = 'INTERNAL'
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the HomematicIP alarm control devices."""
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the HomematicIP Cloud alarm control devices."""
|
||||
pass
|
||||
|
||||
|
||||
@ -45,7 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
|
||||
|
||||
class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel):
|
||||
"""Representation of an HomematicIP security zone group."""
|
||||
"""Representation of an HomematicIP Cloud security zone group."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize the security zone group."""
|
||||
|
@ -45,7 +45,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
try:
|
||||
simplisafe = SimpliSafeApiInterface(username, password)
|
||||
except SimpliSafeAPIException:
|
||||
_LOGGER.error("Failed to setup SimpliSafe")
|
||||
_LOGGER.error("Failed to set up SimpliSafe")
|
||||
return
|
||||
|
||||
systems = []
|
||||
|
@ -111,6 +111,7 @@ def async_setup(hass, config):
|
||||
|
||||
for alert_id in alert_ids:
|
||||
alert = all_alerts[alert_id]
|
||||
alert.async_set_context(service_call.context)
|
||||
if service_call.service == SERVICE_TURN_ON:
|
||||
yield from alert.async_turn_on()
|
||||
elif service_call.service == SERVICE_TOGGLE:
|
||||
|
@ -13,12 +13,13 @@ import homeassistant.util.color as color_util
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, CONF_NAME,
|
||||
SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
|
||||
ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS,
|
||||
CONF_UNIT_OF_MEASUREMENT, STATE_LOCKED, STATE_UNLOCKED, STATE_ON)
|
||||
STATE_LOCKED, STATE_UNLOCKED, STATE_ON)
|
||||
|
||||
from .const import CONF_FILTER, CONF_ENTITY_CONFIG
|
||||
|
||||
@ -53,6 +54,7 @@ CONF_DISPLAY_CATEGORIES = 'display_categories'
|
||||
|
||||
HANDLERS = Registry()
|
||||
ENTITY_ADAPTERS = Registry()
|
||||
EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
|
||||
|
||||
|
||||
class _DisplayCategory:
|
||||
@ -159,7 +161,8 @@ class _AlexaEntity:
|
||||
The API handlers should manipulate entities only through this interface.
|
||||
"""
|
||||
|
||||
def __init__(self, config, entity):
|
||||
def __init__(self, hass, config, entity):
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.entity = entity
|
||||
self.entity_conf = config.entity_config.get(entity.entity_id, {})
|
||||
@ -383,6 +386,10 @@ class _AlexaInputController(_AlexaInterface):
|
||||
|
||||
|
||||
class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
def __init__(self, hass, entity):
|
||||
_AlexaInterface.__init__(self, entity)
|
||||
self.hass = hass
|
||||
|
||||
def name(self):
|
||||
return 'Alexa.TemperatureSensor'
|
||||
|
||||
@ -396,9 +403,10 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
if name != 'temperature':
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
temp = self.entity.state
|
||||
if self.entity.domain == climate.DOMAIN:
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
temp = self.entity.attributes.get(
|
||||
climate.ATTR_CURRENT_TEMPERATURE)
|
||||
return {
|
||||
@ -408,6 +416,10 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
|
||||
|
||||
class _AlexaThermostatController(_AlexaInterface):
|
||||
def __init__(self, hass, entity):
|
||||
_AlexaInterface.__init__(self, entity)
|
||||
self.hass = hass
|
||||
|
||||
def name(self):
|
||||
return 'Alexa.ThermostatController'
|
||||
|
||||
@ -438,8 +450,7 @@ class _AlexaThermostatController(_AlexaInterface):
|
||||
raise _UnsupportedProperty(name)
|
||||
return mode
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
temp = None
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
if name == 'targetSetpoint':
|
||||
temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE)
|
||||
elif name == 'lowerSetpoint':
|
||||
@ -490,8 +501,8 @@ class _ClimateCapabilities(_AlexaEntity):
|
||||
return [_DisplayCategory.THERMOSTAT]
|
||||
|
||||
def interfaces(self):
|
||||
yield _AlexaThermostatController(self.entity)
|
||||
yield _AlexaTemperatureSensor(self.entity)
|
||||
yield _AlexaThermostatController(self.hass, self.entity)
|
||||
yield _AlexaTemperatureSensor(self.hass, self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(cover.DOMAIN)
|
||||
@ -608,11 +619,11 @@ class _SensorCapabilities(_AlexaEntity):
|
||||
|
||||
def interfaces(self):
|
||||
attrs = self.entity.attributes
|
||||
if attrs.get(CONF_UNIT_OF_MEASUREMENT) in (
|
||||
if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in (
|
||||
TEMP_FAHRENHEIT,
|
||||
TEMP_CELSIUS,
|
||||
):
|
||||
yield _AlexaTemperatureSensor(self.entity)
|
||||
yield _AlexaTemperatureSensor(self.hass, self.entity)
|
||||
|
||||
|
||||
class _Cause:
|
||||
@ -703,24 +714,47 @@ class SmartHomeView(http.HomeAssistantView):
|
||||
return b'' if response is None else self.json(response)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, config, message):
|
||||
async def async_handle_message(hass, config, request, context=None):
|
||||
"""Handle incoming API messages."""
|
||||
assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
|
||||
assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
|
||||
|
||||
if context is None:
|
||||
context = ha.Context()
|
||||
|
||||
# Read head data
|
||||
message = message[API_DIRECTIVE]
|
||||
namespace = message[API_HEADER]['namespace']
|
||||
name = message[API_HEADER]['name']
|
||||
request = request[API_DIRECTIVE]
|
||||
namespace = request[API_HEADER]['namespace']
|
||||
name = request[API_HEADER]['name']
|
||||
|
||||
# Do we support this API request?
|
||||
funct_ref = HANDLERS.get((namespace, name))
|
||||
if not funct_ref:
|
||||
if funct_ref:
|
||||
response = await funct_ref(hass, config, request, context)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Unsupported API request %s/%s", namespace, name)
|
||||
return api_error(message)
|
||||
response = api_error(request)
|
||||
|
||||
return (yield from funct_ref(hass, config, message))
|
||||
request_info = {
|
||||
'namespace': namespace,
|
||||
'name': name,
|
||||
}
|
||||
|
||||
if API_ENDPOINT in request and 'endpointId' in request[API_ENDPOINT]:
|
||||
request_info['entity_id'] = \
|
||||
request[API_ENDPOINT]['endpointId'].replace('#', '.')
|
||||
|
||||
response_header = response[API_EVENT][API_HEADER]
|
||||
|
||||
hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, {
|
||||
'request': request_info,
|
||||
'response': {
|
||||
'namespace': response_header['namespace'],
|
||||
'name': response_header['name'],
|
||||
}
|
||||
}, context=context)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def api_message(request,
|
||||
@ -784,8 +818,7 @@ def api_error(request,
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
|
||||
@asyncio.coroutine
|
||||
def async_api_discovery(hass, config, request):
|
||||
async def async_api_discovery(hass, config, request, context):
|
||||
"""Create a API formatted discovery response.
|
||||
|
||||
Async friendly.
|
||||
@ -800,7 +833,7 @@ def async_api_discovery(hass, config, request):
|
||||
|
||||
if entity.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity)
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity)
|
||||
|
||||
endpoint = {
|
||||
'displayCategories': alexa_entity.display_categories(),
|
||||
@ -827,8 +860,7 @@ def async_api_discovery(hass, config, request):
|
||||
|
||||
def extract_entity(funct):
|
||||
"""Decorate for extract entity object from request."""
|
||||
@asyncio.coroutine
|
||||
def async_api_entity_wrapper(hass, config, request):
|
||||
async def async_api_entity_wrapper(hass, config, request, context):
|
||||
"""Process a turn on request."""
|
||||
entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.')
|
||||
|
||||
@ -839,15 +871,14 @@ def extract_entity(funct):
|
||||
request[API_HEADER]['name'], entity_id)
|
||||
return api_error(request, error_type='NO_SUCH_ENDPOINT')
|
||||
|
||||
return (yield from funct(hass, config, request, entity))
|
||||
return await funct(hass, config, request, context, entity)
|
||||
|
||||
return async_api_entity_wrapper
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_on(hass, config, request, entity):
|
||||
async def async_api_turn_on(hass, config, request, context, entity):
|
||||
"""Process a turn on request."""
|
||||
domain = entity.domain
|
||||
if entity.domain == group.DOMAIN:
|
||||
@ -857,17 +888,16 @@ def async_api_turn_on(hass, config, request, entity):
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
|
||||
yield from hass.services.async_call(domain, service, {
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_off(hass, config, request, entity):
|
||||
async def async_api_turn_off(hass, config, request, context, entity):
|
||||
"""Process a turn off request."""
|
||||
domain = entity.domain
|
||||
if entity.domain == group.DOMAIN:
|
||||
@ -877,32 +907,30 @@ def async_api_turn_off(hass, config, request, entity):
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
|
||||
yield from hass.services.async_call(domain, service, {
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_brightness(hass, config, request, entity):
|
||||
async def async_api_set_brightness(hass, config, request, context, entity):
|
||||
"""Process a set brightness request."""
|
||||
brightness = int(request[API_PAYLOAD]['brightness'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_brightness(hass, config, request, entity):
|
||||
async def async_api_adjust_brightness(hass, config, request, context, entity):
|
||||
"""Process an adjust brightness request."""
|
||||
brightness_delta = int(request[API_PAYLOAD]['brightnessDelta'])
|
||||
|
||||
@ -915,18 +943,17 @@ def async_api_adjust_brightness(hass, config, request, entity):
|
||||
|
||||
# set brightness
|
||||
brightness = max(0, brightness_delta + current)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color(hass, config, request, entity):
|
||||
async def async_api_set_color(hass, config, request, context, entity):
|
||||
"""Process a set color request."""
|
||||
rgb = color_util.color_hsb_to_RGB(
|
||||
float(request[API_PAYLOAD]['color']['hue']),
|
||||
@ -934,25 +961,25 @@ def async_api_set_color(hass, config, request, entity):
|
||||
float(request[API_PAYLOAD]['color']['brightness'])
|
||||
)
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_RGB_COLOR: rgb,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color_temperature(hass, config, request, entity):
|
||||
async def async_api_set_color_temperature(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a set color temperature request."""
|
||||
kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_KELVIN: kelvin,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@ -960,17 +987,17 @@ def async_api_set_color_temperature(hass, config, request, entity):
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_decrease_color_temp(hass, config, request, entity):
|
||||
async def async_api_decrease_color_temp(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a decrease color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
|
||||
|
||||
value = min(max_mireds, current + 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@ -978,31 +1005,30 @@ def async_api_decrease_color_temp(hass, config, request, entity):
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_increase_color_temp(hass, config, request, entity):
|
||||
async def async_api_increase_color_temp(hass, config, request, context,
|
||||
entity):
|
||||
"""Process an increase color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
|
||||
|
||||
value = max(min_mireds, current - 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Activate'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_activate(hass, config, request, entity):
|
||||
async def async_api_activate(hass, config, request, context, entity):
|
||||
"""Process an activate request."""
|
||||
domain = entity.domain
|
||||
|
||||
yield from hass.services.async_call(domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': _Cause.VOICE_INTERACTION},
|
||||
@ -1019,14 +1045,13 @@ def async_api_activate(hass, config, request, entity):
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_deactivate(hass, config, request, entity):
|
||||
async def async_api_deactivate(hass, config, request, context, entity):
|
||||
"""Process a deactivate request."""
|
||||
domain = entity.domain
|
||||
|
||||
yield from hass.services.async_call(domain, SERVICE_TURN_OFF, {
|
||||
await hass.services.async_call(domain, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': _Cause.VOICE_INTERACTION},
|
||||
@ -1043,8 +1068,7 @@ def async_api_deactivate(hass, config, request, entity):
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_percentage(hass, config, request, entity):
|
||||
async def async_api_set_percentage(hass, config, request, context, entity):
|
||||
"""Process a set percentage request."""
|
||||
percentage = int(request[API_PAYLOAD]['percentage'])
|
||||
service = None
|
||||
@ -1066,16 +1090,15 @@ def async_api_set_percentage(hass, config, request, entity):
|
||||
service = SERVICE_SET_COVER_POSITION
|
||||
data[cover.ATTR_POSITION] = percentage
|
||||
|
||||
yield from hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False)
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_percentage(hass, config, request, entity):
|
||||
async def async_api_adjust_percentage(hass, config, request, context, entity):
|
||||
"""Process an adjust percentage request."""
|
||||
percentage_delta = int(request[API_PAYLOAD]['percentageDelta'])
|
||||
service = None
|
||||
@ -1114,20 +1137,19 @@ def async_api_adjust_percentage(hass, config, request, entity):
|
||||
|
||||
data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
|
||||
|
||||
yield from hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False)
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.LockController', 'Lock'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_lock(hass, config, request, entity):
|
||||
async def async_api_lock(hass, config, request, context, entity):
|
||||
"""Process a lock request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_LOCK, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_LOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
# Alexa expects a lockState in the response, we don't know the actual
|
||||
# lockState at this point but assume it is locked. It is reported
|
||||
@ -1144,20 +1166,18 @@ def async_api_lock(hass, config, request, entity):
|
||||
# Not supported by Alexa yet
|
||||
@HANDLERS.register(('Alexa.LockController', 'Unlock'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_unlock(hass, config, request, entity):
|
||||
async def async_api_unlock(hass, config, request, context, entity):
|
||||
"""Process an unlock request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_volume(hass, config, request, entity):
|
||||
async def async_api_set_volume(hass, config, request, context, entity):
|
||||
"""Process a set volume request."""
|
||||
volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2)
|
||||
|
||||
@ -1166,17 +1186,16 @@ def async_api_set_volume(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_SET,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.InputController', 'SelectInput'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_select_input(hass, config, request, entity):
|
||||
async def async_api_select_input(hass, config, request, context, entity):
|
||||
"""Process a set input request."""
|
||||
media_input = request[API_PAYLOAD]['input']
|
||||
|
||||
@ -1200,17 +1219,16 @@ def async_api_select_input(hass, config, request, entity):
|
||||
media_player.ATTR_INPUT_SOURCE: media_input,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_SELECT_SOURCE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_volume(hass, config, request, entity):
|
||||
async def async_api_adjust_volume(hass, config, request, context, entity):
|
||||
"""Process an adjust volume request."""
|
||||
volume_delta = int(request[API_PAYLOAD]['volume'])
|
||||
|
||||
@ -1229,17 +1247,16 @@ def async_api_adjust_volume(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_SET,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
async def async_api_adjust_volume_step(hass, config, request, context, entity):
|
||||
"""Process an adjust volume step request."""
|
||||
# media_player volume up/down service does not support specifying steps
|
||||
# each component handles it differently e.g. via config.
|
||||
@ -1252,13 +1269,13 @@ def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
}
|
||||
|
||||
if volume_step > 0:
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_UP,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
elif volume_step < 0:
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_DOWN,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@ -1266,8 +1283,7 @@ def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute'))
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetMute'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_mute(hass, config, request, entity):
|
||||
async def async_api_set_mute(hass, config, request, context, entity):
|
||||
"""Process a set mute request."""
|
||||
mute = bool(request[API_PAYLOAD]['mute'])
|
||||
|
||||
@ -1276,98 +1292,94 @@ def async_api_set_mute(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_MUTE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Play'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_play(hass, config, request, entity):
|
||||
async def async_api_play(hass, config, request, context, entity):
|
||||
"""Process a play request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PLAY,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Pause'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_pause(hass, config, request, entity):
|
||||
async def async_api_pause(hass, config, request, context, entity):
|
||||
"""Process a pause request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PAUSE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Stop'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_stop(hass, config, request, entity):
|
||||
async def async_api_stop(hass, config, request, context, entity):
|
||||
"""Process a stop request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_STOP,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Next'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_next(hass, config, request, entity):
|
||||
async def async_api_next(hass, config, request, context, entity):
|
||||
"""Process a next request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_NEXT_TRACK,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Previous'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_previous(hass, config, request, entity):
|
||||
async def async_api_previous(hass, config, request, context, entity):
|
||||
"""Process a previous request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
def api_error_temp_range(request, temp, min_temp, max_temp, unit):
|
||||
def api_error_temp_range(hass, request, temp, min_temp, max_temp):
|
||||
"""Create temperature value out of range API error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
unit = hass.config.units.temperature_unit
|
||||
temp_range = {
|
||||
'minimumValue': {
|
||||
'value': min_temp,
|
||||
@ -1388,8 +1400,9 @@ def api_error_temp_range(request, temp, min_temp, max_temp, unit):
|
||||
)
|
||||
|
||||
|
||||
def temperature_from_object(temp_obj, to_unit, interval=False):
|
||||
def temperature_from_object(hass, temp_obj, interval=False):
|
||||
"""Get temperature from Temperature object in requested unit."""
|
||||
to_unit = hass.config.units.temperature_unit
|
||||
from_unit = TEMP_CELSIUS
|
||||
temp = float(temp_obj['value'])
|
||||
|
||||
@ -1405,9 +1418,8 @@ def temperature_from_object(temp_obj, to_unit, interval=False):
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_set_target_temp(hass, config, request, entity):
|
||||
async def async_api_set_target_temp(hass, config, request, context, entity):
|
||||
"""Process a set target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
@ -1417,48 +1429,45 @@ async def async_api_set_target_temp(hass, config, request, entity):
|
||||
|
||||
payload = request[API_PAYLOAD]
|
||||
if 'targetSetpoint' in payload:
|
||||
temp = temperature_from_object(
|
||||
payload['targetSetpoint'], unit)
|
||||
temp = temperature_from_object(hass, payload['targetSetpoint'])
|
||||
if temp < min_temp or temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp, min_temp, max_temp, unit)
|
||||
hass, request, temp, min_temp, max_temp)
|
||||
data[ATTR_TEMPERATURE] = temp
|
||||
if 'lowerSetpoint' in payload:
|
||||
temp_low = temperature_from_object(
|
||||
payload['lowerSetpoint'], unit)
|
||||
temp_low = temperature_from_object(hass, payload['lowerSetpoint'])
|
||||
if temp_low < min_temp or temp_low > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_low, min_temp, max_temp, unit)
|
||||
hass, request, temp_low, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
|
||||
if 'upperSetpoint' in payload:
|
||||
temp_high = temperature_from_object(
|
||||
payload['upperSetpoint'], unit)
|
||||
temp_high = temperature_from_object(hass, payload['upperSetpoint'])
|
||||
if temp_high < min_temp or temp_high > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_high, min_temp, max_temp, unit)
|
||||
hass, request, temp_high, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_adjust_target_temp(hass, config, request, entity):
|
||||
async def async_api_adjust_target_temp(hass, config, request, context, entity):
|
||||
"""Process an adjust target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
temp_delta = temperature_from_object(
|
||||
request[API_PAYLOAD]['targetSetpointDelta'], unit, interval=True)
|
||||
hass, request[API_PAYLOAD]['targetSetpointDelta'], interval=True)
|
||||
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
|
||||
|
||||
if target_temp < min_temp or target_temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, target_temp, min_temp, max_temp, unit)
|
||||
hass, request, target_temp, min_temp, max_temp)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
@ -1466,14 +1475,16 @@ async def async_api_adjust_target_temp(hass, config, request, entity):
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
|
||||
@extract_entity
|
||||
async def async_api_set_thermostat_mode(hass, config, request, entity):
|
||||
async def async_api_set_thermostat_mode(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a set thermostat mode request."""
|
||||
mode = request[API_PAYLOAD]['thermostatMode']
|
||||
mode = mode if isinstance(mode, str) else mode['value']
|
||||
@ -1499,17 +1510,16 @@ async def async_api_set_thermostat_mode(hass, config, request, entity):
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
|
||||
blocking=False)
|
||||
blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa', 'ReportState'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_reportstate(hass, config, request, entity):
|
||||
async def async_api_reportstate(hass, config, request, context, entity):
|
||||
"""Process a ReportState request."""
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity)
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity)
|
||||
properties = []
|
||||
for interface in alexa_entity.interfaces():
|
||||
properties.extend(interface.serialize_properties())
|
||||
|
@ -24,7 +24,7 @@ from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -102,7 +102,7 @@ class APIEventStream(HomeAssistantView):
|
||||
if event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
data = stop_obj
|
||||
else:
|
||||
data = json.dumps(event, cls=rem.JSONEncoder)
|
||||
data = json.dumps(event, cls=JSONEncoder)
|
||||
|
||||
await to_write.put(data)
|
||||
|
||||
|
@ -44,13 +44,26 @@ a limited expiration.
|
||||
"expires_in": 1800,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
|
||||
## Revoking a refresh token
|
||||
|
||||
It is also possible to revoke a refresh token and all access tokens that have
|
||||
ever been granted by that refresh token. Response code will ALWAYS be 200.
|
||||
|
||||
{
|
||||
"token": "IJKLMNOPQRST",
|
||||
"action": "revoke"
|
||||
}
|
||||
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.models import User, Credentials
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http.ban import log_invalid_auth
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
@ -68,37 +81,40 @@ SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_CURRENT_USER,
|
||||
})
|
||||
|
||||
RESULT_TYPE_CREDENTIALS = 'credentials'
|
||||
RESULT_TYPE_USER = 'user'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Component to allow users to login."""
|
||||
store_credentials, retrieve_credentials = _create_cred_store()
|
||||
store_result, retrieve_result = _create_auth_code_store()
|
||||
|
||||
hass.http.register_view(GrantTokenView(retrieve_credentials))
|
||||
hass.http.register_view(LinkUserView(retrieve_credentials))
|
||||
hass.http.register_view(TokenView(retrieve_result))
|
||||
hass.http.register_view(LinkUserView(retrieve_result))
|
||||
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_CURRENT_USER, websocket_current_user,
|
||||
SCHEMA_WS_CURRENT_USER
|
||||
)
|
||||
|
||||
await login_flow.async_setup(hass, store_credentials)
|
||||
await login_flow.async_setup(hass, store_result)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class GrantTokenView(HomeAssistantView):
|
||||
"""View to grant tokens."""
|
||||
class TokenView(HomeAssistantView):
|
||||
"""View to issue or revoke tokens."""
|
||||
|
||||
url = '/auth/token'
|
||||
name = 'api:auth:token'
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
|
||||
def __init__(self, retrieve_credentials):
|
||||
"""Initialize the grant token view."""
|
||||
self._retrieve_credentials = retrieve_credentials
|
||||
def __init__(self, retrieve_user):
|
||||
"""Initialize the token view."""
|
||||
self._retrieve_user = retrieve_user
|
||||
|
||||
@log_invalid_auth
|
||||
async def post(self, request):
|
||||
@ -108,6 +124,13 @@ class GrantTokenView(HomeAssistantView):
|
||||
|
||||
grant_type = data.get('grant_type')
|
||||
|
||||
# IndieAuth 6.3.5
|
||||
# The revocation endpoint is the same as the token endpoint.
|
||||
# The revocation request includes an additional parameter,
|
||||
# action=revoke.
|
||||
if data.get('action') == 'revoke':
|
||||
return await self._async_handle_revoke_token(hass, data)
|
||||
|
||||
if grant_type == 'authorization_code':
|
||||
return await self._async_handle_auth_code(hass, data)
|
||||
|
||||
@ -118,6 +141,25 @@ class GrantTokenView(HomeAssistantView):
|
||||
'error': 'unsupported_grant_type',
|
||||
}, status_code=400)
|
||||
|
||||
async def _async_handle_revoke_token(self, hass, data):
|
||||
"""Handle revoke token request."""
|
||||
# OAuth 2.0 Token Revocation [RFC7009]
|
||||
# 2.2 The authorization server responds with HTTP status code 200
|
||||
# if the token has been revoked successfully or if the client
|
||||
# submitted an invalid token.
|
||||
token = data.get('token')
|
||||
|
||||
if token is None:
|
||||
return web.Response(status=200)
|
||||
|
||||
refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
|
||||
|
||||
if refresh_token is None:
|
||||
return web.Response(status=200)
|
||||
|
||||
await hass.auth.async_remove_refresh_token(refresh_token)
|
||||
return web.Response(status=200)
|
||||
|
||||
async def _async_handle_auth_code(self, hass, data):
|
||||
"""Handle authorization code request."""
|
||||
client_id = data.get('client_id')
|
||||
@ -132,17 +174,19 @@ class GrantTokenView(HomeAssistantView):
|
||||
if code is None:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid code',
|
||||
}, status_code=400)
|
||||
|
||||
credentials = self._retrieve_credentials(client_id, code)
|
||||
user = self._retrieve_user(client_id, RESULT_TYPE_USER, code)
|
||||
|
||||
if credentials is None:
|
||||
if user is None or not isinstance(user, User):
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid code',
|
||||
}, status_code=400)
|
||||
|
||||
user = await hass.auth.async_get_or_create_user(credentials)
|
||||
# refresh user
|
||||
user = await hass.auth.async_get_user(user.id)
|
||||
|
||||
if not user.is_active:
|
||||
return self.json({
|
||||
@ -220,7 +264,7 @@ class LinkUserView(HomeAssistantView):
|
||||
user = request['hass_user']
|
||||
|
||||
credentials = self._retrieve_credentials(
|
||||
data['client_id'], data['code'])
|
||||
data['client_id'], RESULT_TYPE_CREDENTIALS, data['code'])
|
||||
|
||||
if credentials is None:
|
||||
return self.json_message('Invalid code', status_code=400)
|
||||
@ -230,37 +274,45 @@ class LinkUserView(HomeAssistantView):
|
||||
|
||||
|
||||
@callback
|
||||
def _create_cred_store():
|
||||
"""Create a credential store."""
|
||||
temp_credentials = {}
|
||||
def _create_auth_code_store():
|
||||
"""Create an in memory store."""
|
||||
temp_results = {}
|
||||
|
||||
@callback
|
||||
def store_credentials(client_id, credentials):
|
||||
"""Store credentials and return a code to retrieve it."""
|
||||
def store_result(client_id, result):
|
||||
"""Store flow result and return a code to retrieve it."""
|
||||
if isinstance(result, User):
|
||||
result_type = RESULT_TYPE_USER
|
||||
elif isinstance(result, Credentials):
|
||||
result_type = RESULT_TYPE_CREDENTIALS
|
||||
else:
|
||||
raise ValueError('result has to be either User or Credentials')
|
||||
|
||||
code = uuid.uuid4().hex
|
||||
temp_credentials[(client_id, code)] = (dt_util.utcnow(), credentials)
|
||||
temp_results[(client_id, result_type, code)] = \
|
||||
(dt_util.utcnow(), result_type, result)
|
||||
return code
|
||||
|
||||
@callback
|
||||
def retrieve_credentials(client_id, code):
|
||||
"""Retrieve credentials."""
|
||||
key = (client_id, code)
|
||||
def retrieve_result(client_id, result_type, code):
|
||||
"""Retrieve flow result."""
|
||||
key = (client_id, result_type, code)
|
||||
|
||||
if key not in temp_credentials:
|
||||
if key not in temp_results:
|
||||
return None
|
||||
|
||||
created, credentials = temp_credentials.pop(key)
|
||||
created, _, result = temp_results.pop(key)
|
||||
|
||||
# OAuth 4.2.1
|
||||
# The authorization code MUST expire shortly after it is issued to
|
||||
# mitigate the risk of leaks. A maximum authorization code lifetime of
|
||||
# 10 minutes is RECOMMENDED.
|
||||
if dt_util.utcnow() - created < timedelta(minutes=10):
|
||||
return credentials
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
return store_credentials, retrieve_credentials
|
||||
return store_result, retrieve_result
|
||||
|
||||
|
||||
@callback
|
||||
|
@ -4,6 +4,7 @@ from html.parser import HTMLParser
|
||||
from ipaddress import ip_address, ip_network
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
|
||||
# IP addresses of loopback interfaces
|
||||
@ -76,18 +77,17 @@ async def fetch_redirect_uris(hass, url):
|
||||
|
||||
We do not implement extracting redirect uris from headers.
|
||||
"""
|
||||
session = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
parser = LinkTagParser('redirect_uri')
|
||||
chunks = 0
|
||||
try:
|
||||
resp = await session.get(url, timeout=5)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=5) as resp:
|
||||
async for data in resp.content.iter_chunked(1024):
|
||||
parser.feed(data.decode())
|
||||
chunks += 1
|
||||
|
||||
async for data in resp.content.iter_chunked(1024):
|
||||
parser.feed(data.decode())
|
||||
chunks += 1
|
||||
|
||||
if chunks == 10:
|
||||
break
|
||||
if chunks == 10:
|
||||
break
|
||||
|
||||
except (asyncio.TimeoutError, ClientError):
|
||||
pass
|
||||
|
@ -22,10 +22,14 @@ Pass in parameter 'client_id' and 'redirect_url' validate by indieauth.
|
||||
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
||||
are identified by type and id.
|
||||
|
||||
And optional parameter 'type' has to set as 'link_user' if login flow used for
|
||||
link credential to exist user. Default 'type' is 'authorize'.
|
||||
|
||||
{
|
||||
"client_id": "https://hassbian.local:8123/",
|
||||
"handler": ["local_provider", null],
|
||||
"redirect_url": "https://hassbian.local:8123/"
|
||||
"redirect_url": "https://hassbian.local:8123/",
|
||||
"type': "authorize"
|
||||
}
|
||||
|
||||
Return value will be a step in a data entry flow. See the docs for data entry
|
||||
@ -49,6 +53,9 @@ flow for details.
|
||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||
have type "create_entry" and "result" key will contain an authorization code.
|
||||
The authorization code associated with an authorized user by default, it will
|
||||
associate with an credential if "type" set to "link_user" in
|
||||
"/auth/login_flow"
|
||||
|
||||
{
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
@ -71,12 +78,12 @@ from homeassistant.components.http.view import HomeAssistantView
|
||||
from . import indieauth
|
||||
|
||||
|
||||
async def async_setup(hass, store_credentials):
|
||||
async def async_setup(hass, store_result):
|
||||
"""Component to allow users to login."""
|
||||
hass.http.register_view(AuthProvidersView)
|
||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
||||
hass.http.register_view(
|
||||
LoginFlowResourceView(hass.auth.login_flow, store_credentials))
|
||||
LoginFlowResourceView(hass.auth.login_flow, store_result))
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
@ -138,6 +145,7 @@ class LoginFlowIndexView(HomeAssistantView):
|
||||
vol.Required('client_id'): str,
|
||||
vol.Required('handler'): vol.Any(str, list),
|
||||
vol.Required('redirect_uri'): str,
|
||||
vol.Optional('type', default='authorize'): str,
|
||||
}))
|
||||
@log_invalid_auth
|
||||
async def post(self, request, data):
|
||||
@ -153,7 +161,10 @@ class LoginFlowIndexView(HomeAssistantView):
|
||||
|
||||
try:
|
||||
result = await self._flow_mgr.async_init(
|
||||
handler, context={'ip_address': request[KEY_REAL_IP]})
|
||||
handler, context={
|
||||
'ip_address': request[KEY_REAL_IP],
|
||||
'credential_only': data.get('type') == 'link_user',
|
||||
})
|
||||
except data_entry_flow.UnknownHandler:
|
||||
return self.json_message('Invalid handler specified', 404)
|
||||
except data_entry_flow.UnknownStep:
|
||||
@ -169,10 +180,10 @@ class LoginFlowResourceView(HomeAssistantView):
|
||||
name = 'api:auth:login_flow:resource'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, flow_mgr, store_credentials):
|
||||
def __init__(self, flow_mgr, store_result):
|
||||
"""Initialize the login flow resource view."""
|
||||
self._flow_mgr = flow_mgr
|
||||
self._store_credentials = store_credentials
|
||||
self._store_result = store_result
|
||||
|
||||
async def get(self, request):
|
||||
"""Do not allow getting status of a flow in progress."""
|
||||
@ -212,7 +223,7 @@ class LoginFlowResourceView(HomeAssistantView):
|
||||
return self.json(_prepare_result_json(result))
|
||||
|
||||
result.pop('data')
|
||||
result['result'] = self._store_credentials(client_id, result['result'])
|
||||
result['result'] = self._store_result(client_id, result['result'])
|
||||
|
||||
return self.json(result)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Allow to setup simple automation rules via the config file.
|
||||
Allow to set up simple automation rules via the config file.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/automation/
|
||||
|
@ -58,7 +58,7 @@ async def async_setup(hass, config):
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Setup a config entry."""
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
|
@ -71,7 +71,10 @@ class BMWConnectedDriveSensor(BinarySensorDevice):
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Data update is triggered from BMWConnectedDriveEntity."""
|
||||
"""Return False.
|
||||
|
||||
Data update is triggered from BMWConnectedDriveEntity.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
|
@ -58,7 +58,7 @@ class EgardiaBinarySensor(BinarySensorDevice):
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The name of the device."""
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
@ -74,5 +74,5 @@ class EgardiaBinarySensor(BinarySensorDevice):
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""The device class."""
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
|
@ -90,7 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
data = HikvisionData(hass, url, port, name, username, password)
|
||||
|
||||
if data.sensors is None:
|
||||
_LOGGER.error("Hikvision event stream has no data, unable to setup")
|
||||
_LOGGER.error("Hikvision event stream has no data, unable to set up")
|
||||
return False
|
||||
|
||||
entities = []
|
||||
|
@ -5,27 +5,28 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.homematic/
|
||||
"""
|
||||
import logging
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES
|
||||
from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['homematic']
|
||||
|
||||
SENSOR_TYPES_CLASS = {
|
||||
'Remote': None,
|
||||
'ShutterContact': 'opening',
|
||||
'MaxShutterContact': 'opening',
|
||||
'IPShutterContact': 'opening',
|
||||
'Smoke': 'smoke',
|
||||
'SmokeV2': 'smoke',
|
||||
'MaxShutterContact': 'opening',
|
||||
'Motion': 'motion',
|
||||
'MotionV2': 'motion',
|
||||
'RemoteMotion': None,
|
||||
'WeatherSensor': None,
|
||||
'TiltSensor': None,
|
||||
'PresenceIP': 'motion',
|
||||
'Remote': None,
|
||||
'RemoteMotion': None,
|
||||
'ShutterContact': 'opening',
|
||||
'Smoke': 'smoke',
|
||||
'SmokeV2': 'smoke',
|
||||
'TiltSensor': None,
|
||||
'WeatherSensor': None,
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,16 +1,15 @@
|
||||
"""
|
||||
Support for HomematicIP binary sensor.
|
||||
Support for HomematicIP Cloud binary sensor.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
|
||||
HMIPC_HAPID)
|
||||
HMIPC_HAPID, HomematicipGenericDevice)
|
||||
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
|
||||
|
||||
DEPENDENCIES = ['homematicip_cloud']
|
||||
|
||||
@ -19,14 +18,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STATE_SMOKE_OFF = 'IDLE_OFF'
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the binary sensor devices."""
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the HomematicIP Cloud binary sensor devices."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the HomematicIP binary sensor from a config entry."""
|
||||
"""Set up the HomematicIP Cloud binary sensor from a config entry."""
|
||||
from homematicip.aio.device import (
|
||||
AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector)
|
||||
|
||||
@ -45,7 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
|
||||
|
||||
class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice):
|
||||
"""HomematicIP shutter contact."""
|
||||
"""Representation of a HomematicIP Cloud shutter contact."""
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
@ -65,7 +64,7 @@ class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice):
|
||||
|
||||
|
||||
class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice):
|
||||
"""HomematicIP motion detector."""
|
||||
"""Representation of a HomematicIP Cloud motion detector."""
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
@ -81,7 +80,7 @@ class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice):
|
||||
|
||||
|
||||
class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice):
|
||||
"""HomematicIP smoke detector."""
|
||||
"""Representation of a HomematicIP Cloud smoke detector."""
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
|
@ -2,15 +2,15 @@
|
||||
Support for INSTEON dimmers via PowerLinc Modem.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.insteon_plm/
|
||||
https://home-assistant.io/components/binary_sensor.insteon/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.insteon_plm import InsteonPLMEntity
|
||||
from homeassistant.components.insteon import InsteonEntity
|
||||
|
||||
DEPENDENCIES = ['insteon_plm']
|
||||
DEPENDENCIES = ['insteon']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -24,27 +24,27 @@ SENSOR_TYPES = {'openClosedSensor': 'opening',
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the INSTEON PLM device class for the hass platform."""
|
||||
plm = hass.data['insteon_plm'].get('plm')
|
||||
"""Set up the INSTEON device class for the hass platform."""
|
||||
insteon_modem = hass.data['insteon'].get('modem')
|
||||
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
device = insteon_modem.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
name = device.states[state_key].name
|
||||
if name != 'dryLeakSensor':
|
||||
_LOGGER.debug('Adding device %s entity %s to Binary Sensor platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
new_entity = InsteonPLMBinarySensor(device, state_key)
|
||||
new_entity = InsteonBinarySensor(device, state_key)
|
||||
|
||||
async_add_devices([new_entity])
|
||||
|
||||
|
||||
class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice):
|
||||
class InsteonBinarySensor(InsteonEntity, BinarySensorDevice):
|
||||
"""A Class for an Insteon device entity."""
|
||||
|
||||
def __init__(self, device, state_key):
|
||||
"""Initialize the INSTEON PLM binary sensor."""
|
||||
"""Initialize the INSTEON binary sensor."""
|
||||
super().__init__(device, state_key)
|
||||
self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name)
|
||||
|
@ -105,7 +105,7 @@ class KNXBinarySensor(BinarySensorDevice):
|
||||
|
||||
def __init__(self, hass, device):
|
||||
"""Initialize of KNX binary sensor."""
|
||||
self.device = device
|
||||
self._device = device
|
||||
self.hass = hass
|
||||
self.async_register_callbacks()
|
||||
self.automations = []
|
||||
@ -116,12 +116,12 @@ class KNXBinarySensor(BinarySensorDevice):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
self._device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the KNX device."""
|
||||
return self.device.name
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
@ -136,9 +136,9 @@ class KNXBinarySensor(BinarySensorDevice):
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return self.device.device_class
|
||||
return self._device.device_class
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.device.is_on()
|
||||
return self._device.is_on()
|
||||
|
@ -130,7 +130,7 @@ class NestBinarySensor(NestSensorDevice, BinarySensorDevice):
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
value = getattr(self.device, self.variable)
|
||||
value = getattr(self._device, self.variable)
|
||||
if self.variable in STRUCTURE_BINARY_TYPES:
|
||||
self._state = bool(STRUCTURE_BINARY_STATE_MAP
|
||||
[self.variable].get(value))
|
||||
@ -154,4 +154,5 @@ class NestActivityZoneSensor(NestBinarySensor):
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id)
|
||||
self._state = self._device.has_ongoing_motion_in_zone(
|
||||
self.zone.zone_id)
|
||||
|
@ -31,4 +31,4 @@ class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice):
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self.device.is_on
|
||||
return self._device.is_on
|
||||
|
@ -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.15.0']
|
||||
REQUIREMENTS = ['numpy==1.15.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -28,11 +28,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']:
|
||||
devices.append(XiaomiMotionSensor(device, hass, gateway))
|
||||
elif model in ['magnet', 'sensor_magnet', 'sensor_magnet.aq2']:
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'window_status'
|
||||
devices.append(XiaomiDoorSensor(device, data_key, gateway))
|
||||
devices.append(XiaomiDoorSensor(device, gateway))
|
||||
elif model == 'sensor_wleak.aq1':
|
||||
devices.append(XiaomiWaterLeakSensor(device, gateway))
|
||||
elif model in ['smoke', 'sensor_smoke']:
|
||||
@ -44,17 +40,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'channel_0'
|
||||
data_key = 'button_0'
|
||||
devices.append(XiaomiButton(device, 'Switch', data_key,
|
||||
hass, gateway))
|
||||
elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1']:
|
||||
devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0',
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'channel_0'
|
||||
else:
|
||||
data_key = 'button_0'
|
||||
devices.append(XiaomiButton(device, 'Wall Switch', data_key,
|
||||
hass, gateway))
|
||||
elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1']:
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key_left = 'channel_0'
|
||||
data_key_right = 'channel_1'
|
||||
else:
|
||||
data_key_left = 'button_0'
|
||||
data_key_right = 'button_1'
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Left)',
|
||||
'channel_0', hass, gateway))
|
||||
data_key_left, hass, gateway))
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Right)',
|
||||
'channel_1', hass, gateway))
|
||||
data_key_right, hass, gateway))
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Both)',
|
||||
'dual_channel', hass, gateway))
|
||||
elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']:
|
||||
@ -119,7 +125,7 @@ class XiaomiNatgasSensor(XiaomiBinarySensor):
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if value == '1':
|
||||
if value in ('1', '2'):
|
||||
if self._state:
|
||||
return False
|
||||
self._state = True
|
||||
@ -194,9 +200,13 @@ class XiaomiMotionSensor(XiaomiBinarySensor):
|
||||
class XiaomiDoorSensor(XiaomiBinarySensor):
|
||||
"""Representation of a XiaomiDoorSensor."""
|
||||
|
||||
def __init__(self, device, data_key, xiaomi_hub):
|
||||
def __init__(self, device, xiaomi_hub):
|
||||
"""Initialize the XiaomiDoorSensor."""
|
||||
self._open_since = 0
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'window_status'
|
||||
XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor',
|
||||
xiaomi_hub, data_key, 'opening')
|
||||
|
||||
@ -237,8 +247,12 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor):
|
||||
|
||||
def __init__(self, device, xiaomi_hub):
|
||||
"""Initialize the XiaomiWaterLeakSensor."""
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'wleak_status'
|
||||
XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor',
|
||||
xiaomi_hub, 'status', 'moisture')
|
||||
xiaomi_hub, data_key, 'moisture')
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
@ -285,7 +299,7 @@ class XiaomiSmokeSensor(XiaomiBinarySensor):
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if value == '1':
|
||||
if value in ('1', '2'):
|
||||
if self._state:
|
||||
return False
|
||||
self._state = True
|
||||
@ -359,6 +373,10 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
self._hass = hass
|
||||
self._last_action = None
|
||||
self._state = False
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
self._data_key = 'status'
|
||||
else:
|
||||
self._data_key = 'cube_status'
|
||||
XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub,
|
||||
None, None)
|
||||
|
||||
@ -371,19 +389,12 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
if 'status' in data:
|
||||
if self._data_key in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': data['status']
|
||||
'action_type': data[self._data_key]
|
||||
})
|
||||
self._last_action = data['status']
|
||||
|
||||
if 'cube_status' in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': data['cube_status']
|
||||
})
|
||||
self._last_action = data['cube_status']
|
||||
self._last_action = data[self._data_key]
|
||||
|
||||
if 'rotate' in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
|
@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'bmw_connected_drive'
|
||||
CONF_REGION = 'region'
|
||||
CONF_READ_ONLY = 'read_only'
|
||||
ATTR_VIN = 'vin'
|
||||
|
||||
ACCOUNT_SCHEMA = vol.Schema({
|
||||
@ -27,6 +28,7 @@ ACCOUNT_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_REGION): vol.Any('north_america', 'china',
|
||||
'rest_of_world'),
|
||||
vol.Optional(CONF_READ_ONLY, default=False): cv.boolean,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
@ -82,8 +84,10 @@ def setup_account(account_config: dict, hass, name: str) \
|
||||
username = account_config[CONF_USERNAME]
|
||||
password = account_config[CONF_PASSWORD]
|
||||
region = account_config[CONF_REGION]
|
||||
read_only = account_config[CONF_READ_ONLY]
|
||||
_LOGGER.debug('Adding new account %s', name)
|
||||
cd_account = BMWConnectedDriveAccount(username, password, region, name)
|
||||
cd_account = BMWConnectedDriveAccount(username, password, region, name,
|
||||
read_only)
|
||||
|
||||
def execute_service(call):
|
||||
"""Execute a service for a vehicle.
|
||||
@ -99,13 +103,13 @@ def setup_account(account_config: dict, hass, name: str) \
|
||||
function_name = _SERVICE_MAP[call.service]
|
||||
function_call = getattr(vehicle.remote_services, function_name)
|
||||
function_call()
|
||||
|
||||
# register the remote services
|
||||
for service in _SERVICE_MAP:
|
||||
hass.services.register(
|
||||
DOMAIN, service,
|
||||
execute_service,
|
||||
schema=SERVICE_SCHEMA)
|
||||
if not read_only:
|
||||
# register the remote services
|
||||
for service in _SERVICE_MAP:
|
||||
hass.services.register(
|
||||
DOMAIN, service,
|
||||
execute_service,
|
||||
schema=SERVICE_SCHEMA)
|
||||
|
||||
# update every UPDATE_INTERVAL minutes, starting now
|
||||
# this should even out the load on the servers
|
||||
@ -122,13 +126,14 @@ class BMWConnectedDriveAccount:
|
||||
"""Representation of a BMW vehicle."""
|
||||
|
||||
def __init__(self, username: str, password: str, region_str: str,
|
||||
name: str) -> None:
|
||||
name: str, read_only) -> None:
|
||||
"""Constructor."""
|
||||
from bimmer_connected.account import ConnectedDriveAccount
|
||||
from bimmer_connected.country_selector import get_region_from_name
|
||||
|
||||
region = get_region_from_name(region_str)
|
||||
|
||||
self.read_only = read_only
|
||||
self.account = ConnectedDriveAccount(username, password, region)
|
||||
self.name = name
|
||||
self._update_listeners = []
|
||||
|
@ -145,7 +145,7 @@ async def async_get_image(hass, entity_id, timeout=10):
|
||||
component = hass.data.get(DOMAIN)
|
||||
|
||||
if component is None:
|
||||
raise HomeAssistantError('Camera component not setup')
|
||||
raise HomeAssistantError('Camera component not set up')
|
||||
|
||||
camera = component.get_entity(entity_id)
|
||||
|
||||
@ -214,7 +214,7 @@ async def async_setup(hass, config):
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Setup a config entry."""
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
|
@ -15,7 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.components.camera import (
|
||||
PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, Camera)
|
||||
@ -42,6 +42,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string,
|
||||
vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||
})
|
||||
|
||||
|
||||
@ -65,6 +66,7 @@ class GenericCamera(Camera):
|
||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||
self.content_type = device_info[CONF_CONTENT_TYPE]
|
||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
||||
|
||||
username = device_info.get(CONF_USERNAME)
|
||||
password = device_info.get(CONF_PASSWORD)
|
||||
@ -108,7 +110,8 @@ class GenericCamera(Camera):
|
||||
def fetch():
|
||||
"""Read image from a URL."""
|
||||
try:
|
||||
response = requests.get(url, timeout=10, auth=self._auth)
|
||||
response = requests.get(url, timeout=10, auth=self._auth,
|
||||
verify=self.verify_ssl)
|
||||
return response.content
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error("Error getting camera image: %s", error)
|
||||
@ -119,7 +122,8 @@ class GenericCamera(Camera):
|
||||
# async
|
||||
else:
|
||||
try:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
websession = async_get_clientsession(
|
||||
self.hass, verify_ssl=self.verify_ssl)
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
response = yield from websession.get(
|
||||
url, auth=self._auth)
|
||||
|
@ -46,7 +46,7 @@ class NestCamera(Camera):
|
||||
"""Initialize a Nest Camera."""
|
||||
super(NestCamera, self).__init__()
|
||||
self.structure = structure
|
||||
self.device = device
|
||||
self._device = device
|
||||
self._location = None
|
||||
self._name = None
|
||||
self._online = None
|
||||
@ -93,7 +93,7 @@ class NestCamera(Camera):
|
||||
# Calling Nest API in is_streaming setter.
|
||||
# device.is_streaming would not immediately change until the process
|
||||
# finished in Nest Cam.
|
||||
self.device.is_streaming = False
|
||||
self._device.is_streaming = False
|
||||
|
||||
def turn_on(self):
|
||||
"""Turn on camera."""
|
||||
@ -105,15 +105,15 @@ class NestCamera(Camera):
|
||||
# Calling Nest API in is_streaming setter.
|
||||
# device.is_streaming would not immediately change until the process
|
||||
# finished in Nest Cam.
|
||||
self.device.is_streaming = True
|
||||
self._device.is_streaming = True
|
||||
|
||||
def update(self):
|
||||
"""Cache value from Python-nest."""
|
||||
self._location = self.device.where
|
||||
self._name = self.device.name
|
||||
self._online = self.device.online
|
||||
self._is_streaming = self.device.is_streaming
|
||||
self._is_video_history_enabled = self.device.is_video_history_enabled
|
||||
self._location = self._device.where
|
||||
self._name = self._device.name
|
||||
self._online = self._device.online
|
||||
self._is_streaming = self._device.is_streaming
|
||||
self._is_video_history_enabled = self._device.is_video_history_enabled
|
||||
|
||||
if self._is_video_history_enabled:
|
||||
# NestAware allowed 10/min
|
||||
@ -130,7 +130,7 @@ class NestCamera(Camera):
|
||||
"""Return a still image response from the camera."""
|
||||
now = utcnow()
|
||||
if self._ready_for_snapshot(now):
|
||||
url = self.device.snapshot_url
|
||||
url = self._device.snapshot_url
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
|
@ -183,7 +183,7 @@ class ONVIFHassCamera(Camera):
|
||||
_LOGGER.debug("Camera '%s' doesn't support PTZ.", self._name)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Callback when entity is added to hass."""
|
||||
"""Handle entity addition to hass."""
|
||||
if ONVIF_DATA not in self.hass.data:
|
||||
self.hass.data[ONVIF_DATA] = {}
|
||||
self.hass.data[ONVIF_DATA][ENTITIES] = []
|
||||
|
@ -21,6 +21,8 @@ import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
CONF_BUFFER_SIZE = 'buffer'
|
||||
CONF_IMAGE_FIELD = 'field'
|
||||
|
||||
@ -111,7 +113,7 @@ class PushCamera(Camera):
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Current state of the camera."""
|
||||
"""Return current state of the camera."""
|
||||
return self._state
|
||||
|
||||
async def update_image(self, image, filename):
|
||||
|
@ -6,10 +6,10 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to setup Google Cast?",
|
||||
"description": "Do you want to set up Google Cast?",
|
||||
"title": "Google Cast"
|
||||
}
|
||||
},
|
||||
"title": "Google Cast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "Google Cast",
|
||||
"description": "Do you want to setup Google Cast?"
|
||||
"description": "Do you want to set up Google Cast?"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@ -294,7 +294,7 @@ async def async_setup(hass, config):
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Setup a config entry."""
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
@ -320,7 +320,7 @@ class ClimateDevice(Entity):
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the precision of the system."""
|
||||
if self.unit_of_measurement == TEMP_CELSIUS:
|
||||
if self.hass.config.units.temperature_unit == TEMP_CELSIUS:
|
||||
return PRECISION_TENTHS
|
||||
return PRECISION_WHOLE
|
||||
|
||||
@ -394,11 +394,6 @@ class ClimateDevice(Entity):
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement to display."""
|
||||
return self.hass.config.units.temperature_unit
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement used by the platform."""
|
||||
|
@ -16,9 +16,8 @@ from homeassistant.components.climate import (
|
||||
ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE,
|
||||
CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
STATE_UNKNOWN)
|
||||
STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN)
|
||||
from homeassistant.helpers import condition
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change, async_track_time_interval)
|
||||
@ -297,11 +296,8 @@ class GenericThermostat(ClimateDevice):
|
||||
@callback
|
||||
def _async_update_temp(self, state):
|
||||
"""Update thermostat with latest state from sensor."""
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
try:
|
||||
self._cur_temp = self.hass.config.units.temperature(
|
||||
float(state.state), unit)
|
||||
self._cur_temp = float(state.state)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error("Unable to update from sensor: %s", ex)
|
||||
|
||||
|
@ -58,7 +58,6 @@ class HeatmiserV3Thermostat(ClimateDevice):
|
||||
def __init__(self, heatmiser, device, name, serport):
|
||||
"""Initialize the thermostat."""
|
||||
self.heatmiser = heatmiser
|
||||
self.device = device
|
||||
self.serport = serport
|
||||
self._current_temperature = None
|
||||
self._name = name
|
||||
|
@ -5,12 +5,13 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.homematic/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_OPERATION_MODE)
|
||||
STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice)
|
||||
from homeassistant.components.homematic import (
|
||||
HMDevice, ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT)
|
||||
from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE
|
||||
ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS
|
||||
|
||||
DEPENDENCIES = ['homematic']
|
||||
|
||||
|
@ -1,19 +1,18 @@
|
||||
"""
|
||||
Support for HomematicIP climate.
|
||||
Support for HomematicIP Cloud climate devices.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, SUPPORT_TARGET_TEMPERATURE, ATTR_TEMPERATURE,
|
||||
STATE_AUTO, STATE_MANUAL)
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
ATTR_TEMPERATURE, STATE_AUTO, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice)
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
|
||||
HMIPC_HAPID)
|
||||
HMIPC_HAPID, HomematicipGenericDevice)
|
||||
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -27,9 +26,9 @@ HA_STATE_TO_HMIP = {
|
||||
HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()}
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the HomematicIP climate devices."""
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the HomematicIP Cloud climate devices."""
|
||||
pass
|
||||
|
||||
|
||||
@ -48,7 +47,7 @@ async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
|
||||
|
||||
class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice):
|
||||
"""Representation of a MomematicIP heating group."""
|
||||
"""Representation of a HomematicIP heating group."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize heating group."""
|
||||
|
@ -118,17 +118,15 @@ class KNXClimate(ClimateDevice):
|
||||
|
||||
def __init__(self, hass, device):
|
||||
"""Initialize of a KNX climate device."""
|
||||
self.device = device
|
||||
self._device = device
|
||||
self.hass = hass
|
||||
self.async_register_callbacks()
|
||||
|
||||
self._unit_of_measurement = TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
support = SUPPORT_TARGET_TEMPERATURE
|
||||
if self.device.supports_operation_mode:
|
||||
if self._device.supports_operation_mode:
|
||||
support |= SUPPORT_OPERATION_MODE
|
||||
return support
|
||||
|
||||
@ -137,12 +135,12 @@ class KNXClimate(ClimateDevice):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
self._device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the KNX device."""
|
||||
return self.device.name
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
@ -157,46 +155,46 @@ class KNXClimate(ClimateDevice):
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit_of_measurement
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self.device.temperature.value
|
||||
return self._device.temperature.value
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
return self.device.setpoint_shift_step
|
||||
return self._device.setpoint_shift_step
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self.device.target_temperature.value
|
||||
return self._device.target_temperature.value
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self.device.target_temperature_min
|
||||
return self._device.target_temperature_min
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self.device.target_temperature_max
|
||||
return self._device.target_temperature_max
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None:
|
||||
return
|
||||
await self.device.set_target_temperature(temperature)
|
||||
await self._device.set_target_temperature(temperature)
|
||||
await self.async_update_ha_state()
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self.device.supports_operation_mode:
|
||||
return self.device.operation_mode.value
|
||||
if self._device.supports_operation_mode:
|
||||
return self._device.operation_mode.value
|
||||
return None
|
||||
|
||||
@property
|
||||
@ -204,11 +202,11 @@ class KNXClimate(ClimateDevice):
|
||||
"""Return the list of available operation modes."""
|
||||
return [operation_mode.value for
|
||||
operation_mode in
|
||||
self.device.get_supported_operation_modes()]
|
||||
self._device.get_supported_operation_modes()]
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if self.device.supports_operation_mode:
|
||||
if self._device.supports_operation_mode:
|
||||
from xknx.knx import HVACOperationMode
|
||||
knx_operation_mode = HVACOperationMode(operation_mode)
|
||||
await self.device.set_operation_mode(knx_operation_mode)
|
||||
await self._device.set_operation_mode(knx_operation_mode)
|
||||
|
@ -45,7 +45,6 @@ class MaxCubeClimate(ClimateDevice):
|
||||
def __init__(self, handler, name, rf_address):
|
||||
"""Initialize MAX! Cube ClimateDevice."""
|
||||
self._name = name
|
||||
self._unit_of_measurement = TEMP_CELSIUS
|
||||
self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST,
|
||||
STATE_VACATION]
|
||||
self._rf_address = rf_address
|
||||
@ -81,7 +80,7 @@ class MaxCubeClimate(ClimateDevice):
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit_of_measurement
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
|
@ -166,7 +166,7 @@ class MelissaClimate(ClimateDevice):
|
||||
self.send({self._api.STATE: self._api.STATE_OFF})
|
||||
|
||||
def send(self, value):
|
||||
"""Sending action to service."""
|
||||
"""Send action to service."""
|
||||
try:
|
||||
old_value = self._cur_settings.copy()
|
||||
self._cur_settings.update(value)
|
||||
|
@ -57,7 +57,7 @@ class NestThermostat(ClimateDevice):
|
||||
"""Initialize the thermostat."""
|
||||
self._unit = temp_unit
|
||||
self.structure = structure
|
||||
self.device = device
|
||||
self._device = device
|
||||
self._fan_list = [STATE_ON, STATE_AUTO]
|
||||
|
||||
# Set the default supported features
|
||||
@ -68,13 +68,13 @@ class NestThermostat(ClimateDevice):
|
||||
self._operation_list = [STATE_OFF]
|
||||
|
||||
# Add supported nest thermostat features
|
||||
if self.device.can_heat:
|
||||
if self._device.can_heat:
|
||||
self._operation_list.append(STATE_HEAT)
|
||||
|
||||
if self.device.can_cool:
|
||||
if self._device.can_cool:
|
||||
self._operation_list.append(STATE_COOL)
|
||||
|
||||
if self.device.can_heat and self.device.can_cool:
|
||||
if self._device.can_heat and self._device.can_cool:
|
||||
self._operation_list.append(STATE_AUTO)
|
||||
self._support_flags = (self._support_flags |
|
||||
SUPPORT_TARGET_TEMPERATURE_HIGH |
|
||||
@ -83,7 +83,7 @@ class NestThermostat(ClimateDevice):
|
||||
self._operation_list.append(STATE_ECO)
|
||||
|
||||
# feature of device
|
||||
self._has_fan = self.device.has_fan
|
||||
self._has_fan = self._device.has_fan
|
||||
if self._has_fan:
|
||||
self._support_flags = (self._support_flags | SUPPORT_FAN_MODE)
|
||||
|
||||
@ -124,8 +124,8 @@ class NestThermostat(ClimateDevice):
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Unique ID for this device."""
|
||||
return self.device.serial
|
||||
"""Return unique ID for this device."""
|
||||
return self._device.serial
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -202,7 +202,7 @@ class NestThermostat(ClimateDevice):
|
||||
_LOGGER.debug("Nest set_temperature-output-value=%s", temp)
|
||||
try:
|
||||
if temp is not None:
|
||||
self.device.target = temp
|
||||
self._device.target = temp
|
||||
except nest.nest.APIError as api_error:
|
||||
_LOGGER.error("An error occurred while setting temperature: %s",
|
||||
api_error)
|
||||
@ -220,7 +220,7 @@ class NestThermostat(ClimateDevice):
|
||||
_LOGGER.error(
|
||||
"An error occurred while setting device mode. "
|
||||
"Invalid operation mode: %s", operation_mode)
|
||||
self.device.mode = device_mode
|
||||
self._device.mode = device_mode
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
@ -254,7 +254,7 @@ class NestThermostat(ClimateDevice):
|
||||
def set_fan_mode(self, fan_mode):
|
||||
"""Turn fan on/off."""
|
||||
if self._has_fan:
|
||||
self.device.fan = fan_mode.lower()
|
||||
self._device.fan = fan_mode.lower()
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
@ -268,20 +268,20 @@ class NestThermostat(ClimateDevice):
|
||||
|
||||
def update(self):
|
||||
"""Cache value from Python-nest."""
|
||||
self._location = self.device.where
|
||||
self._name = self.device.name
|
||||
self._humidity = self.device.humidity
|
||||
self._temperature = self.device.temperature
|
||||
self._mode = self.device.mode
|
||||
self._target_temperature = self.device.target
|
||||
self._fan = self.device.fan
|
||||
self._location = self._device.where
|
||||
self._name = self._device.name
|
||||
self._humidity = self._device.humidity
|
||||
self._temperature = self._device.temperature
|
||||
self._mode = self._device.mode
|
||||
self._target_temperature = self._device.target
|
||||
self._fan = self._device.fan
|
||||
self._away = self.structure.away == 'away'
|
||||
self._eco_temperature = self.device.eco_temperature
|
||||
self._locked_temperature = self.device.locked_temperature
|
||||
self._min_temperature = self.device.min_temperature
|
||||
self._max_temperature = self.device.max_temperature
|
||||
self._is_locked = self.device.is_locked
|
||||
if self.device.temperature_scale == 'C':
|
||||
self._eco_temperature = self._device.eco_temperature
|
||||
self._locked_temperature = self._device.locked_temperature
|
||||
self._min_temperature = self._device.min_temperature
|
||||
self._max_temperature = self._device.max_temperature
|
||||
self._is_locked = self._device.is_locked
|
||||
if self._device.temperature_scale == 'C':
|
||||
self._temperature_scale = TEMP_CELSIUS
|
||||
else:
|
||||
self._temperature_scale = TEMP_FAHRENHEIT
|
||||
|
@ -120,7 +120,7 @@ class RadioThermostat(ClimateDevice):
|
||||
|
||||
def __init__(self, device, hold_temp, away_temps):
|
||||
"""Initialize the thermostat."""
|
||||
self.device = device
|
||||
self._device = device
|
||||
self._target_temperature = None
|
||||
self._current_temperature = None
|
||||
self._current_operation = STATE_IDLE
|
||||
@ -137,8 +137,8 @@ class RadioThermostat(ClimateDevice):
|
||||
|
||||
# Fan circulate mode is only supported by the CT80 models.
|
||||
import radiotherm
|
||||
self._is_model_ct80 = isinstance(self.device,
|
||||
radiotherm.thermostat.CT80)
|
||||
self._is_model_ct80 = isinstance(
|
||||
self._device, radiotherm.thermostat.CT80)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
@ -194,7 +194,7 @@ class RadioThermostat(ClimateDevice):
|
||||
"""Turn fan on/off."""
|
||||
code = FAN_MODE_TO_CODE.get(fan_mode, None)
|
||||
if code is not None:
|
||||
self.device.fmode = code
|
||||
self._device.fmode = code
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
@ -234,15 +234,15 @@ class RadioThermostat(ClimateDevice):
|
||||
# First time - get the name from the thermostat. This is
|
||||
# normally set in the radio thermostat web app.
|
||||
if self._name is None:
|
||||
self._name = self.device.name['raw']
|
||||
self._name = self._device.name['raw']
|
||||
|
||||
# Request the current state from the thermostat.
|
||||
data = self.device.tstat['raw']
|
||||
data = self._device.tstat['raw']
|
||||
|
||||
current_temp = data['temp']
|
||||
if current_temp == -1:
|
||||
_LOGGER.error('%s (%s) was busy (temp == -1)', self._name,
|
||||
self.device.host)
|
||||
self._device.host)
|
||||
return
|
||||
|
||||
# Map thermostat values into various STATE_ flags.
|
||||
@ -277,30 +277,30 @@ class RadioThermostat(ClimateDevice):
|
||||
temperature = round_temp(temperature)
|
||||
|
||||
if self._current_operation == STATE_COOL:
|
||||
self.device.t_cool = temperature
|
||||
self._device.t_cool = temperature
|
||||
elif self._current_operation == STATE_HEAT:
|
||||
self.device.t_heat = temperature
|
||||
self._device.t_heat = temperature
|
||||
elif self._current_operation == STATE_AUTO:
|
||||
if self._tstate == STATE_COOL:
|
||||
self.device.t_cool = temperature
|
||||
self._device.t_cool = temperature
|
||||
elif self._tstate == STATE_HEAT:
|
||||
self.device.t_heat = temperature
|
||||
self._device.t_heat = temperature
|
||||
|
||||
# Only change the hold if requested or if hold mode was turned
|
||||
# on and we haven't set it yet.
|
||||
if kwargs.get('hold_changed', False) or not self._hold_set:
|
||||
if self._hold_temp or self._away:
|
||||
self.device.hold = 1
|
||||
self._device.hold = 1
|
||||
self._hold_set = True
|
||||
else:
|
||||
self.device.hold = 0
|
||||
self._device.hold = 0
|
||||
|
||||
def set_time(self):
|
||||
"""Set device time."""
|
||||
# Calling this clears any local temperature override and
|
||||
# reverts to the scheduled temperature.
|
||||
now = datetime.datetime.now()
|
||||
self.device.time = {
|
||||
self._device.time = {
|
||||
'day': now.weekday(),
|
||||
'hour': now.hour,
|
||||
'minute': now.minute
|
||||
@ -309,13 +309,13 @@ class RadioThermostat(ClimateDevice):
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode (auto, cool, heat, off)."""
|
||||
if operation_mode in (STATE_OFF, STATE_AUTO):
|
||||
self.device.tmode = TEMP_MODE_TO_CODE[operation_mode]
|
||||
self._device.tmode = TEMP_MODE_TO_CODE[operation_mode]
|
||||
|
||||
# Setting t_cool or t_heat automatically changes tmode.
|
||||
elif operation_mode == STATE_COOL:
|
||||
self.device.t_cool = self._target_temperature
|
||||
self._device.t_cool = self._target_temperature
|
||||
elif operation_mode == STATE_HEAT:
|
||||
self.device.t_heat = self._target_temperature
|
||||
self._device.t_heat = self._target_temperature
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Turn away on.
|
||||
|
@ -71,7 +71,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
for dev in (
|
||||
yield from client.async_get_devices(_INITIAL_FETCH_FIELDS)):
|
||||
if config[CONF_ID] == ALL or dev['id'] in config[CONF_ID]:
|
||||
devices.append(SensiboClimate(client, dev))
|
||||
devices.append(SensiboClimate(
|
||||
client, dev, hass.config.units.temperature_unit))
|
||||
except (aiohttp.client_exceptions.ClientConnectorError,
|
||||
asyncio.TimeoutError):
|
||||
_LOGGER.exception('Failed to connect to Sensibo servers.')
|
||||
@ -106,7 +107,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class SensiboClimate(ClimateDevice):
|
||||
"""Representation of a Sensibo device."""
|
||||
|
||||
def __init__(self, client, data):
|
||||
def __init__(self, client, data, units):
|
||||
"""Build SensiboClimate.
|
||||
|
||||
client: aiohttp session.
|
||||
@ -115,6 +116,7 @@ class SensiboClimate(ClimateDevice):
|
||||
self._client = client
|
||||
self._id = data['id']
|
||||
self._external_state = None
|
||||
self._units = units
|
||||
self._do_update(data)
|
||||
|
||||
@property
|
||||
@ -139,7 +141,7 @@ class SensiboClimate(ClimateDevice):
|
||||
self._temperatures_list = self._current_capabilities[
|
||||
'temperatures'].get(temperature_unit_key, {}).get('values', [])
|
||||
else:
|
||||
self._temperature_unit = self.unit_of_measurement
|
||||
self._temperature_unit = self._units
|
||||
self._temperatures_list = []
|
||||
self._supported_features = 0
|
||||
for key in self._ac_states:
|
||||
@ -175,7 +177,7 @@ class SensiboClimate(ClimateDevice):
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
if self.temperature_unit == self.unit_of_measurement:
|
||||
if self.temperature_unit == self.hass.config.units.temperature_unit:
|
||||
# We are working in same units as the a/c unit. Use whole degrees
|
||||
# like the API supports.
|
||||
return 1
|
||||
|
@ -100,7 +100,7 @@ class ZhongHongClimate(ClimateDevice):
|
||||
async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADDED)
|
||||
|
||||
def _after_update(self, climate):
|
||||
"""Callback to update state."""
|
||||
"""Handle state update."""
|
||||
_LOGGER.debug("async update ha state")
|
||||
if self._device.current_operation:
|
||||
self._current_operation = self._device.current_operation.lower()
|
||||
|
@ -101,7 +101,7 @@ def websocket_update_entity(hass, connection, msg):
|
||||
|
||||
@callback
|
||||
def _entry_dict(entry):
|
||||
"""Helper to convert entry to API format."""
|
||||
"""Convert entry to API format."""
|
||||
return {
|
||||
'entity_id': entry.entity_id,
|
||||
'name': entry.name
|
||||
|
@ -212,7 +212,7 @@ class ZWaveProtectionView(HomeAssistantView):
|
||||
network = hass.data.get(const.DATA_NETWORK)
|
||||
|
||||
def _fetch_protection():
|
||||
"""Helper to get protection data."""
|
||||
"""Get protection data."""
|
||||
node = network.nodes.get(nodeid)
|
||||
if node is None:
|
||||
return self.json_message('Node not found', HTTP_NOT_FOUND)
|
||||
@ -236,7 +236,7 @@ class ZWaveProtectionView(HomeAssistantView):
|
||||
protection_data = await request.json()
|
||||
|
||||
def _set_protection():
|
||||
"""Helper to get protection data."""
|
||||
"""Set protection data."""
|
||||
node = network.nodes.get(nodeid)
|
||||
selection = protection_data["selection"]
|
||||
value_id = int(protection_data[const.ATTR_VALUE_ID])
|
||||
|
@ -18,7 +18,7 @@ from homeassistant.components.cover import (
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['brunt==0.1.2']
|
||||
REQUIREMENTS = ['brunt==0.1.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -6,9 +6,9 @@ https://home-assistant.io/components/cover.homematic/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.cover import CoverDevice, ATTR_POSITION,\
|
||||
ATTR_TILT_POSITION
|
||||
from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION, ATTR_TILT_POSITION, CoverDevice)
|
||||
from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@ -96,7 +96,7 @@ class KNXCover(CoverDevice):
|
||||
|
||||
def __init__(self, hass, device):
|
||||
"""Initialize the cover."""
|
||||
self.device = device
|
||||
self._device = device
|
||||
self.hass = hass
|
||||
self.async_register_callbacks()
|
||||
|
||||
@ -108,12 +108,12 @@ class KNXCover(CoverDevice):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
self._device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the KNX device."""
|
||||
return self.device.name
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
@ -130,56 +130,56 @@ class KNXCover(CoverDevice):
|
||||
"""Flag supported features."""
|
||||
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \
|
||||
SUPPORT_SET_POSITION | SUPPORT_STOP
|
||||
if self.device.supports_angle:
|
||||
if self._device.supports_angle:
|
||||
supported_features |= SUPPORT_SET_TILT_POSITION
|
||||
return supported_features
|
||||
|
||||
@property
|
||||
def current_cover_position(self):
|
||||
"""Return the current position of the cover."""
|
||||
return self.device.current_position()
|
||||
return self._device.current_position()
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return if the cover is closed."""
|
||||
return self.device.is_closed()
|
||||
return self._device.is_closed()
|
||||
|
||||
async def async_close_cover(self, **kwargs):
|
||||
"""Close the cover."""
|
||||
if not self.device.is_closed():
|
||||
await self.device.set_down()
|
||||
if not self._device.is_closed():
|
||||
await self._device.set_down()
|
||||
self.start_auto_updater()
|
||||
|
||||
async def async_open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
if not self.device.is_open():
|
||||
await self.device.set_up()
|
||||
if not self._device.is_open():
|
||||
await self._device.set_up()
|
||||
self.start_auto_updater()
|
||||
|
||||
async def async_set_cover_position(self, **kwargs):
|
||||
"""Move the cover to a specific position."""
|
||||
if ATTR_POSITION in kwargs:
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self.device.set_position(position)
|
||||
await self._device.set_position(position)
|
||||
self.start_auto_updater()
|
||||
|
||||
async def async_stop_cover(self, **kwargs):
|
||||
"""Stop the cover."""
|
||||
await self.device.stop()
|
||||
await self._device.stop()
|
||||
self.stop_auto_updater()
|
||||
|
||||
@property
|
||||
def current_cover_tilt_position(self):
|
||||
"""Return current tilt position of cover."""
|
||||
if not self.device.supports_angle:
|
||||
if not self._device.supports_angle:
|
||||
return None
|
||||
return self.device.current_angle()
|
||||
return self._device.current_angle()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs):
|
||||
"""Move the cover tilt to a specific position."""
|
||||
if ATTR_TILT_POSITION in kwargs:
|
||||
tilt_position = kwargs[ATTR_TILT_POSITION]
|
||||
await self.device.set_angle(tilt_position)
|
||||
await self._device.set_angle(tilt_position)
|
||||
|
||||
def start_auto_updater(self):
|
||||
"""Start the autoupdater to update HASS while cover is moving."""
|
||||
@ -197,7 +197,7 @@ class KNXCover(CoverDevice):
|
||||
def auto_updater_hook(self, now):
|
||||
"""Call for the autoupdater."""
|
||||
self.async_schedule_update_ha_state()
|
||||
if self.device.position_reached():
|
||||
if self._device.position_reached():
|
||||
self.stop_auto_updater()
|
||||
|
||||
self.hass.add_job(self.device.auto_stop_if_necessary())
|
||||
self.hass.add_job(self._device.auto_stop_if_necessary())
|
||||
|
@ -28,19 +28,19 @@ class TelldusLiveCover(TelldusLiveEntity, CoverDevice):
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return the current position of the cover."""
|
||||
return self.device.is_down
|
||||
return self._device.is_down
|
||||
|
||||
def close_cover(self, **kwargs):
|
||||
"""Close the cover."""
|
||||
self.device.down()
|
||||
self._device.down()
|
||||
self.changed()
|
||||
|
||||
def open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
self.device.up()
|
||||
self._device.up()
|
||||
self.changed()
|
||||
|
||||
def stop_cover(self, **kwargs):
|
||||
"""Stop the cover."""
|
||||
self.device.stop()
|
||||
self._device.stop()
|
||||
self.changed()
|
||||
|
@ -82,7 +82,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
|
||||
@callback
|
||||
def async_add_device_callback(device_type, device):
|
||||
"""Called when a new device has been created in deCONZ."""
|
||||
"""Handle event of new device creation in deCONZ."""
|
||||
async_dispatcher_send(
|
||||
hass, 'deconz_new_{}'.format(device_type), [device])
|
||||
|
||||
@ -105,7 +105,7 @@ async def async_setup_entry(hass, config_entry):
|
||||
|
||||
@callback
|
||||
def async_add_remote(sensors):
|
||||
"""Setup remote from deCONZ."""
|
||||
"""Set up remote from deCONZ."""
|
||||
from pydeconz.sensor import SWITCH as DECONZ_REMOTE
|
||||
allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True)
|
||||
for sensor in sensors:
|
||||
|
@ -330,19 +330,18 @@ class DeviceTracker:
|
||||
})
|
||||
|
||||
# update known_devices.yaml
|
||||
self.hass.async_add_job(
|
||||
self.hass.async_create_task(
|
||||
self.async_update_config(
|
||||
self.hass.config.path(YAML_DEVICES), dev_id, device)
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update_config(self, path, dev_id, device):
|
||||
async def async_update_config(self, path, dev_id, device):
|
||||
"""Add device to YAML configuration file.
|
||||
|
||||
This method is a coroutine.
|
||||
"""
|
||||
with (yield from self._is_updating):
|
||||
yield from self.hass.async_add_job(
|
||||
async with self._is_updating:
|
||||
await self.hass.async_add_executor_job(
|
||||
update_config, self.hass.config.path(YAML_DEVICES),
|
||||
dev_id, device)
|
||||
|
||||
@ -681,8 +680,7 @@ def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
# Initial scan of each mac we also tell about host name for config
|
||||
seen = set() # type: Any
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_device_tracker_scan(now: dt_util.dt.datetime):
|
||||
async def async_device_tracker_scan(now: dt_util.dt.datetime):
|
||||
"""Handle interval matches."""
|
||||
if update_lock.locked():
|
||||
_LOGGER.warning(
|
||||
@ -690,18 +688,18 @@ def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
"scan interval %s", platform, interval)
|
||||
return
|
||||
|
||||
with (yield from update_lock):
|
||||
found_devices = yield from scanner.async_scan_devices()
|
||||
async with update_lock:
|
||||
found_devices = await scanner.async_scan_devices()
|
||||
|
||||
for mac in found_devices:
|
||||
if mac in seen:
|
||||
host_name = None
|
||||
else:
|
||||
host_name = yield from scanner.async_get_device_name(mac)
|
||||
host_name = await scanner.async_get_device_name(mac)
|
||||
seen.add(mac)
|
||||
|
||||
try:
|
||||
extra_attributes = (yield from
|
||||
extra_attributes = (await
|
||||
scanner.async_get_extra_attributes(mac))
|
||||
except NotImplementedError:
|
||||
extra_attributes = dict()
|
||||
|
@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME
|
||||
)
|
||||
|
||||
REQUIREMENTS = ['ndms2_client==0.0.3']
|
||||
REQUIREMENTS = ['ndms2_client==0.0.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
|
||||
REQUIREMENTS = ['ritassist==0.5']
|
||||
REQUIREMENTS = ['ritassist==0.9.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -57,7 +57,7 @@ class RitAssistDeviceScanner:
|
||||
config.get(CONF_PASSWORD))
|
||||
|
||||
def setup(self, hass):
|
||||
"""Setup a timer and start gathering devices."""
|
||||
"""Set up a timer and start gathering devices."""
|
||||
self._refresh()
|
||||
track_utc_time_change(hass,
|
||||
lambda now: self._refresh(),
|
||||
@ -78,6 +78,10 @@ class RitAssistDeviceScanner:
|
||||
for device in devices:
|
||||
if (not self._include or
|
||||
device.license_plate in self._include):
|
||||
|
||||
if device.active or device.current_address is None:
|
||||
device.get_map_details()
|
||||
|
||||
self._see(dev_id=device.plate_as_id,
|
||||
gps=(device.latitude, device.longitude),
|
||||
attributes=device.state_attributes,
|
||||
|
@ -69,11 +69,16 @@ class TplinkDeviceScanner(DeviceScanner):
|
||||
password = config[CONF_PASSWORD]
|
||||
username = config[CONF_USERNAME]
|
||||
|
||||
self.tplink_client = TpLinkClient(
|
||||
password, host=host, username=username)
|
||||
self.success_init = False
|
||||
try:
|
||||
self.tplink_client = TpLinkClient(
|
||||
password, host=host, username=username)
|
||||
|
||||
self.last_results = {}
|
||||
self.success_init = self._update_info()
|
||||
self.last_results = {}
|
||||
|
||||
self.success_init = self._update_info()
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.debug("ConnectionError in TplinkDeviceScanner")
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
@ -115,7 +120,11 @@ class Tplink1DeviceScanner(DeviceScanner):
|
||||
self.password = password
|
||||
|
||||
self.last_results = {}
|
||||
self.success_init = self._update_info()
|
||||
self.success_init = False
|
||||
try:
|
||||
self.success_init = self._update_info()
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.debug("ConnectionError in Tplink1DeviceScanner")
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
|
@ -20,7 +20,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
|
||||
})
|
||||
|
||||
REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41']
|
||||
REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
@ -73,5 +73,8 @@ class XiaomiMiioDeviceScanner(DeviceScanner):
|
||||
return devices
|
||||
|
||||
async def async_get_device_name(self, device):
|
||||
"""The repeater doesn't provide the name of the associated device."""
|
||||
"""Return None.
|
||||
|
||||
The repeater doesn't provide the name of the associated device.
|
||||
"""
|
||||
return None
|
||||
|
@ -86,11 +86,11 @@ SERVICE_HANDLERS = {
|
||||
'volumio': ('media_player', 'volumio'),
|
||||
'nanoleaf_aurora': ('light', 'nanoleaf_aurora'),
|
||||
'freebox': ('device_tracker', 'freebox'),
|
||||
'dlna_dmr': ('media_player', 'dlna_dmr'),
|
||||
}
|
||||
|
||||
OPTIONAL_SERVICE_HANDLERS = {
|
||||
SERVICE_HOMEKIT: ('homekit_controller', None),
|
||||
'dlna_dmr': ('media_player', 'dlna_dmr'),
|
||||
}
|
||||
|
||||
CONF_IGNORE = 'ignore'
|
||||
|
@ -139,17 +139,17 @@ class ConfiguredDoorbird():
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Custom device name."""
|
||||
"""Get custom device name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
"""The configured device."""
|
||||
"""Get the configured device."""
|
||||
return self._device
|
||||
|
||||
@property
|
||||
def custom_url(self):
|
||||
"""Custom url for device."""
|
||||
"""Get custom url for device."""
|
||||
return self._custom_url
|
||||
|
||||
@property
|
||||
|
87
homeassistant/components/ecovacs.py
Normal file
87
homeassistant/components/ecovacs.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Parent component for Ecovacs Deebot vacuums.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/ecovacs/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, \
|
||||
EVENT_HOMEASSISTANT_STOP
|
||||
|
||||
REQUIREMENTS = ['sucks==0.9.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "ecovacs"
|
||||
|
||||
CONF_COUNTRY = "country"
|
||||
CONF_CONTINENT = "continent"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
|
||||
vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ECOVACS_DEVICES = "ecovacs_devices"
|
||||
|
||||
# Generate a random device ID on each bootup
|
||||
ECOVACS_API_DEVICEID = ''.join(
|
||||
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
||||
)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the Ecovacs component."""
|
||||
_LOGGER.debug("Creating new Ecovacs component")
|
||||
|
||||
hass.data[ECOVACS_DEVICES] = []
|
||||
|
||||
from sucks import EcoVacsAPI, VacBot
|
||||
|
||||
ecovacs_api = EcoVacsAPI(ECOVACS_API_DEVICEID,
|
||||
config[DOMAIN].get(CONF_USERNAME),
|
||||
EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)),
|
||||
config[DOMAIN].get(CONF_COUNTRY),
|
||||
config[DOMAIN].get(CONF_CONTINENT))
|
||||
|
||||
devices = ecovacs_api.devices()
|
||||
_LOGGER.debug("Ecobot devices: %s", devices)
|
||||
|
||||
for device in devices:
|
||||
_LOGGER.info("Discovered Ecovacs device on account: %s",
|
||||
device['nick'])
|
||||
vacbot = VacBot(ecovacs_api.uid,
|
||||
ecovacs_api.REALM,
|
||||
ecovacs_api.resource,
|
||||
ecovacs_api.user_access_token,
|
||||
device,
|
||||
config[DOMAIN].get(CONF_CONTINENT).lower(),
|
||||
monitor=True)
|
||||
hass.data[ECOVACS_DEVICES].append(vacbot)
|
||||
|
||||
def stop(event: object) -> None:
|
||||
"""Shut down open connections to Ecovacs XMPP server."""
|
||||
for device in hass.data[ECOVACS_DEVICES]:
|
||||
_LOGGER.info("Shutting down connection to Ecovacs device %s",
|
||||
device.vacuum['nick'])
|
||||
device.disconnect()
|
||||
|
||||
# Listen for HA stop to disconnect.
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||
|
||||
if hass.data[ECOVACS_DEVICES]:
|
||||
_LOGGER.debug("Starting vacuum components")
|
||||
discovery.load_platform(hass, "vacuum", DOMAIN, {}, config)
|
||||
|
||||
return True
|
@ -101,7 +101,7 @@ def setup(hass, config):
|
||||
server.start()
|
||||
|
||||
def handle_stop_event(event):
|
||||
"""Callback function for HA stop event."""
|
||||
"""Handle HA stop event."""
|
||||
server.stop()
|
||||
|
||||
# listen to home assistant stop event
|
||||
|
@ -2,7 +2,7 @@
|
||||
Support for INSTEON fans via PowerLinc Modem.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/fan.insteon_plm/
|
||||
https://home-assistant.io/components/fan.insteon/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
@ -14,9 +14,9 @@ from homeassistant.components.fan import (SPEED_OFF,
|
||||
FanEntity,
|
||||
SUPPORT_SET_SPEED)
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.components.insteon_plm import InsteonPLMEntity
|
||||
from homeassistant.components.insteon import InsteonEntity
|
||||
|
||||
DEPENDENCIES = ['insteon_plm']
|
||||
DEPENDENCIES = ['insteon']
|
||||
|
||||
SPEED_TO_HEX = {SPEED_OFF: 0x00,
|
||||
SPEED_LOW: 0x3f,
|
||||
@ -30,22 +30,22 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the INSTEON PLM device class for the hass platform."""
|
||||
plm = hass.data['insteon_plm'].get('plm')
|
||||
"""Set up the INSTEON device class for the hass platform."""
|
||||
insteon_modem = hass.data['insteon'].get('modem')
|
||||
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
device = insteon_modem.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
|
||||
_LOGGER.debug('Adding device %s entity %s to Fan platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
new_entity = InsteonPLMFan(device, state_key)
|
||||
new_entity = InsteonFan(device, state_key)
|
||||
|
||||
async_add_devices([new_entity])
|
||||
|
||||
|
||||
class InsteonPLMFan(InsteonPLMEntity, FanEntity):
|
||||
class InsteonFan(InsteonEntity, FanEntity):
|
||||
"""An INSTEON fan component."""
|
||||
|
||||
@property
|
@ -1,107 +0,0 @@
|
||||
"""
|
||||
Support for Insteon fans via local hub control.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/fan.insteon_local/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant import util
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_SPEED, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH,
|
||||
SUPPORT_SET_SPEED, FanEntity)
|
||||
|
||||
_CONFIGURING = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['insteon_local']
|
||||
DOMAIN = 'fan'
|
||||
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
SUPPORT_INSTEON_LOCAL = SUPPORT_SET_SPEED
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Insteon local fan platform."""
|
||||
insteonhub = hass.data['insteon_local']
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
linked = discovery_info['linked']
|
||||
device_list = []
|
||||
for device_id in linked:
|
||||
if (linked[device_id]['cat_type'] == 'dimmer' and
|
||||
linked[device_id]['sku'] == '2475F'):
|
||||
device = insteonhub.fan(device_id)
|
||||
device_list.append(
|
||||
InsteonLocalFanDevice(device)
|
||||
)
|
||||
|
||||
add_devices(device_list)
|
||||
|
||||
|
||||
class InsteonLocalFanDevice(FanEntity):
|
||||
"""An abstract Class for an Insteon node."""
|
||||
|
||||
def __init__(self, node):
|
||||
"""Initialize the device."""
|
||||
self.node = node
|
||||
self._speed = SPEED_OFF
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the node."""
|
||||
return self.node.device_id
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this Insteon node."""
|
||||
return self.node.device_id
|
||||
|
||||
@property
|
||||
def speed(self) -> str:
|
||||
"""Return the current speed."""
|
||||
return self._speed
|
||||
|
||||
@property
|
||||
def speed_list(self) -> list:
|
||||
"""Get the list of available speeds."""
|
||||
return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update(self):
|
||||
"""Update state of the fan."""
|
||||
resp = self.node.status()
|
||||
if 'cmd2' in resp:
|
||||
if resp['cmd2'] == '00':
|
||||
self._speed = SPEED_OFF
|
||||
elif resp['cmd2'] == '55':
|
||||
self._speed = SPEED_LOW
|
||||
elif resp['cmd2'] == 'AA':
|
||||
self._speed = SPEED_MEDIUM
|
||||
elif resp['cmd2'] == 'FF':
|
||||
self._speed = SPEED_HIGH
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_INSTEON_LOCAL
|
||||
|
||||
def turn_on(self, speed: str = None, **kwargs) -> None:
|
||||
"""Turn device on."""
|
||||
if speed is None:
|
||||
speed = kwargs.get(ATTR_SPEED, SPEED_MEDIUM)
|
||||
|
||||
self.set_speed(speed)
|
||||
|
||||
def turn_off(self, **kwargs) -> None:
|
||||
"""Turn device off."""
|
||||
self.node.off()
|
||||
|
||||
def set_speed(self, speed: str) -> None:
|
||||
"""Set the speed of the fan."""
|
||||
if self.node.on(speed):
|
||||
self._speed = speed
|
@ -49,7 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
'zhimi.humidifier.ca1']),
|
||||
})
|
||||
|
||||
REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41']
|
||||
REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41']
|
||||
|
||||
ATTR_MODEL = 'model'
|
||||
|
||||
|
@ -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==20180816.0']
|
||||
REQUIREMENTS = ['home-assistant-frontend==20180820.0']
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log',
|
||||
|
@ -77,7 +77,7 @@ class _GoogleEntity:
|
||||
domain = state.domain
|
||||
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return [Trait(state) for Trait in trait.TRAITS
|
||||
return [Trait(self.hass, state) for Trait in trait.TRAITS
|
||||
if Trait.supported(domain, features)]
|
||||
|
||||
@callback
|
||||
@ -159,7 +159,7 @@ class _GoogleEntity:
|
||||
executed = False
|
||||
for trt in self.traits():
|
||||
if trt.can_execute(command, params):
|
||||
await trt.execute(self.hass, command, params)
|
||||
await trt.execute(command, params)
|
||||
executed = True
|
||||
break
|
||||
|
||||
|
@ -14,7 +14,6 @@ from homeassistant.components import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
@ -50,15 +49,14 @@ TRAITS = []
|
||||
|
||||
|
||||
def register_trait(trait):
|
||||
"""Decorator to register a trait."""
|
||||
"""Decorate a function to register a trait."""
|
||||
TRAITS.append(trait)
|
||||
return trait
|
||||
|
||||
|
||||
def _google_temp_unit(state):
|
||||
def _google_temp_unit(units):
|
||||
"""Return Google temperature unit."""
|
||||
if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) ==
|
||||
TEMP_FAHRENHEIT):
|
||||
if units == TEMP_FAHRENHEIT:
|
||||
return 'F'
|
||||
return 'C'
|
||||
|
||||
@ -68,8 +66,9 @@ class _Trait:
|
||||
|
||||
commands = []
|
||||
|
||||
def __init__(self, state):
|
||||
def __init__(self, hass, state):
|
||||
"""Initialize a trait for a state."""
|
||||
self.hass = hass
|
||||
self.state = state
|
||||
|
||||
def sync_attributes(self):
|
||||
@ -84,7 +83,7 @@ class _Trait:
|
||||
"""Test if command can be executed."""
|
||||
return command in self.commands
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a trait command."""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -141,24 +140,24 @@ class BrightnessTrait(_Trait):
|
||||
|
||||
return response
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a brightness command."""
|
||||
domain = self.state.domain
|
||||
|
||||
if domain == light.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
light.DOMAIN, light.SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: params['brightness']
|
||||
}, blocking=True)
|
||||
elif domain == cover.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
cover.ATTR_POSITION: params['brightness']
|
||||
}, blocking=True)
|
||||
elif domain == media_player.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL:
|
||||
@ -201,7 +200,7 @@ class OnOffTrait(_Trait):
|
||||
return {'on': self.state.state != cover.STATE_CLOSED}
|
||||
return {'on': self.state.state != STATE_OFF}
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute an OnOff command."""
|
||||
domain = self.state.domain
|
||||
|
||||
@ -220,7 +219,7 @@ class OnOffTrait(_Trait):
|
||||
service_domain = domain
|
||||
service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF
|
||||
|
||||
await hass.services.async_call(service_domain, service, {
|
||||
await self.hass.services.async_call(service_domain, service, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
@ -268,14 +267,14 @@ class ColorSpectrumTrait(_Trait):
|
||||
return (command in self.commands and
|
||||
'spectrumRGB' in params.get('color', {}))
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a color spectrum command."""
|
||||
# Convert integer to hex format and left pad with 0's till length 6
|
||||
hex_value = "{0:06x}".format(params['color']['spectrumRGB'])
|
||||
color = color_util.color_RGB_to_hs(
|
||||
*color_util.rgb_hex_to_rgb_list(hex_value))
|
||||
|
||||
await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_HS_COLOR: color
|
||||
}, blocking=True)
|
||||
@ -331,7 +330,7 @@ class ColorTemperatureTrait(_Trait):
|
||||
return (command in self.commands and
|
||||
'temperature' in params.get('color', {}))
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a color temperature command."""
|
||||
temp = color_util.color_temperature_kelvin_to_mired(
|
||||
params['color']['temperature'])
|
||||
@ -344,7 +343,7 @@ class ColorTemperatureTrait(_Trait):
|
||||
"Temperature should be between {} and {}".format(min_temp,
|
||||
max_temp))
|
||||
|
||||
await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_COLOR_TEMP: temp,
|
||||
}, blocking=True)
|
||||
@ -376,12 +375,13 @@ class SceneTrait(_Trait):
|
||||
"""Return scene query attributes."""
|
||||
return {}
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a scene command."""
|
||||
# Don't block for scripts as they can be slow.
|
||||
await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=self.state.domain != script.DOMAIN)
|
||||
await self.hass.services.async_call(
|
||||
self.state.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=self.state.domain != script.DOMAIN)
|
||||
|
||||
|
||||
@register_trait
|
||||
@ -425,7 +425,8 @@ class TemperatureSettingTrait(_Trait):
|
||||
|
||||
return {
|
||||
'availableThermostatModes': ','.join(modes),
|
||||
'thermostatTemperatureUnit': _google_temp_unit(self.state),
|
||||
'thermostatTemperatureUnit': _google_temp_unit(
|
||||
self.hass.config.units.temperature_unit)
|
||||
}
|
||||
|
||||
def query_attributes(self):
|
||||
@ -437,7 +438,7 @@ class TemperatureSettingTrait(_Trait):
|
||||
if operation is not None and operation in self.hass_to_google:
|
||||
response['thermostatMode'] = self.hass_to_google[operation]
|
||||
|
||||
unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT]
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
@ -465,10 +466,10 @@ class TemperatureSettingTrait(_Trait):
|
||||
|
||||
return response
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
async def execute(self, command, params):
|
||||
"""Execute a temperature point or mode command."""
|
||||
# All sent in temperatures are always in Celsius
|
||||
unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT]
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
min_temp = self.state.attributes[climate.ATTR_MIN_TEMP]
|
||||
max_temp = self.state.attributes[climate.ATTR_MAX_TEMP]
|
||||
|
||||
@ -482,7 +483,7 @@ class TemperatureSettingTrait(_Trait):
|
||||
"Temperature should be between {} and {}".format(min_temp,
|
||||
max_temp))
|
||||
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_TEMPERATURE: temp
|
||||
@ -508,7 +509,7 @@ class TemperatureSettingTrait(_Trait):
|
||||
"Lower bound for temperature range should be between "
|
||||
"{} and {}".format(min_temp, max_temp))
|
||||
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_TARGET_TEMP_HIGH: temp_high,
|
||||
@ -516,7 +517,7 @@ class TemperatureSettingTrait(_Trait):
|
||||
}, blocking=True)
|
||||
|
||||
elif command == COMMAND_THERMOSTAT_SET_MODE:
|
||||
await hass.services.async_call(
|
||||
await self.hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_OPERATION_MODE:
|
||||
|
@ -550,12 +550,12 @@ class Group(Entity):
|
||||
self._async_update_group_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Callback when added to HASS."""
|
||||
"""Handle addition to HASS."""
|
||||
if self.tracking:
|
||||
self.async_start()
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Callback when removed from HASS."""
|
||||
"""Handle removal from HASS."""
|
||||
if self._async_unsub_state_changed:
|
||||
self._async_unsub_state_changed()
|
||||
self._async_unsub_state_changed = None
|
||||
|
31
homeassistant/components/hangouts/.translations/en.json
Normal file
31
homeassistant/components/hangouts/.translations/en.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Google Hangouts is already configured",
|
||||
"unknown": "Unknown error occurred."
|
||||
},
|
||||
"error": {
|
||||
"invalid_2fa": "Invalid 2 Factor Authorization, please try again.",
|
||||
"invalid_2fa_method": "Invalig 2FA Method (Verify on Phone).",
|
||||
"invalid_login": "Invalid Login, please try again."
|
||||
},
|
||||
"step": {
|
||||
"2fa": {
|
||||
"data": {
|
||||
"2fa": "2FA Pin"
|
||||
},
|
||||
"description": "",
|
||||
"title": "2-Factor-Authorization"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "E-Mail Address",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "",
|
||||
"title": "Google Hangouts Login"
|
||||
}
|
||||
},
|
||||
"title": "Google Hangouts"
|
||||
}
|
||||
}
|
87
homeassistant/components/hangouts/__init__.py
Normal file
87
homeassistant/components/hangouts/__init__.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
The hangouts bot component.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/hangouts/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.helpers import dispatcher
|
||||
|
||||
from .config_flow import configured_hangouts
|
||||
from .const import (
|
||||
CONF_BOT, CONF_COMMANDS, CONF_REFRESH_TOKEN, DOMAIN,
|
||||
EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||
MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE,
|
||||
SERVICE_UPDATE)
|
||||
|
||||
REQUIREMENTS = ['hangups==0.4.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Hangouts bot component."""
|
||||
config = config.get(DOMAIN, [])
|
||||
hass.data[DOMAIN] = {CONF_COMMANDS: config[CONF_COMMANDS]}
|
||||
|
||||
if configured_hangouts(hass) is None:
|
||||
hass.async_add_job(hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={'source': config_entries.SOURCE_IMPORT}
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config):
|
||||
"""Set up a config entry."""
|
||||
from hangups.auth import GoogleAuthError
|
||||
|
||||
try:
|
||||
from .hangouts_bot import HangoutsBot
|
||||
|
||||
bot = HangoutsBot(
|
||||
hass,
|
||||
config.data.get(CONF_REFRESH_TOKEN),
|
||||
hass.data[DOMAIN][CONF_COMMANDS])
|
||||
hass.data[DOMAIN][CONF_BOT] = bot
|
||||
except GoogleAuthError as exception:
|
||||
_LOGGER.error("Hangouts failed to log in: %s", str(exception))
|
||||
return False
|
||||
|
||||
dispatcher.async_dispatcher_connect(
|
||||
hass,
|
||||
EVENT_HANGOUTS_CONNECTED,
|
||||
bot.async_handle_update_users_and_conversations)
|
||||
|
||||
dispatcher.async_dispatcher_connect(
|
||||
hass,
|
||||
EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||
bot.async_update_conversation_commands)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||
bot.async_handle_hass_stop)
|
||||
|
||||
await bot.async_connect()
|
||||
|
||||
hass.services.async_register(DOMAIN, SERVICE_SEND_MESSAGE,
|
||||
bot.async_handle_send_message,
|
||||
schema=MESSAGE_SCHEMA)
|
||||
hass.services.async_register(DOMAIN,
|
||||
SERVICE_UPDATE,
|
||||
bot.
|
||||
async_handle_update_users_and_conversations,
|
||||
schema=vol.Schema({}))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, _):
|
||||
"""Unload a config entry."""
|
||||
bot = hass.data[DOMAIN].pop(CONF_BOT)
|
||||
await bot.async_disconnect()
|
||||
return True
|
107
homeassistant/components/hangouts/config_flow.py
Normal file
107
homeassistant/components/hangouts/config_flow.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""Config flow to configure Google Hangouts."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import CONF_2FA, CONF_REFRESH_TOKEN
|
||||
from .const import DOMAIN as HANGOUTS_DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def configured_hangouts(hass):
|
||||
"""Return the configures Google Hangouts Account."""
|
||||
entries = hass.config_entries.async_entries(HANGOUTS_DOMAIN)
|
||||
if entries:
|
||||
return entries[0]
|
||||
return None
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(HANGOUTS_DOMAIN)
|
||||
class HangoutsFlowHandler(data_entry_flow.FlowHandler):
|
||||
"""Config flow Google Hangouts."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Google Hangouts config flow."""
|
||||
self._credentials = None
|
||||
self._refresh_token = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow start."""
|
||||
errors = {}
|
||||
|
||||
if configured_hangouts(self.hass) is not None:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
if user_input is not None:
|
||||
from hangups import get_auth
|
||||
from .hangups_utils import (HangoutsCredentials,
|
||||
HangoutsRefreshToken,
|
||||
GoogleAuthError, Google2FAError)
|
||||
self._credentials = HangoutsCredentials(user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD])
|
||||
self._refresh_token = HangoutsRefreshToken(None)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(get_auth,
|
||||
self._credentials,
|
||||
self._refresh_token)
|
||||
|
||||
return await self.async_step_final()
|
||||
except GoogleAuthError as err:
|
||||
if isinstance(err, Google2FAError):
|
||||
return await self.async_step_2fa()
|
||||
msg = str(err)
|
||||
if msg == 'Unknown verification code input':
|
||||
errors['base'] = 'invalid_2fa_method'
|
||||
else:
|
||||
errors['base'] = 'invalid_login'
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str
|
||||
}),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
async def async_step_2fa(self, user_input=None):
|
||||
"""Handle the 2fa step, if needed."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
from hangups import get_auth
|
||||
from .hangups_utils import GoogleAuthError
|
||||
self._credentials.set_verification_code(user_input[CONF_2FA])
|
||||
try:
|
||||
await self.hass.async_add_executor_job(get_auth,
|
||||
self._credentials,
|
||||
self._refresh_token)
|
||||
|
||||
return await self.async_step_final()
|
||||
except GoogleAuthError:
|
||||
errors['base'] = 'invalid_2fa'
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=CONF_2FA,
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_2FA): str,
|
||||
}),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
async def async_step_final(self):
|
||||
"""Handle the final step, create the config entry."""
|
||||
return self.async_create_entry(
|
||||
title=self._credentials.get_email(),
|
||||
data={
|
||||
CONF_EMAIL: self._credentials.get_email(),
|
||||
CONF_REFRESH_TOKEN: self._refresh_token.get()
|
||||
})
|
||||
|
||||
async def async_step_import(self, _):
|
||||
"""Handle a flow import."""
|
||||
return self.async_abort(reason='already_configured')
|
78
homeassistant/components/hangouts/const.py
Normal file
78
homeassistant/components/hangouts/const.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""Constants for Google Hangouts Component."""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TARGET
|
||||
from homeassistant.const import CONF_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger('homeassistant.components.hangouts')
|
||||
|
||||
|
||||
DOMAIN = 'hangouts'
|
||||
|
||||
CONF_2FA = '2fa'
|
||||
CONF_REFRESH_TOKEN = 'refresh_token'
|
||||
CONF_BOT = 'bot'
|
||||
|
||||
CONF_CONVERSATIONS = 'conversations'
|
||||
CONF_DEFAULT_CONVERSATIONS = 'default_conversations'
|
||||
|
||||
CONF_COMMANDS = 'commands'
|
||||
CONF_WORD = 'word'
|
||||
CONF_EXPRESSION = 'expression'
|
||||
|
||||
EVENT_HANGOUTS_COMMAND = 'hangouts_command'
|
||||
|
||||
EVENT_HANGOUTS_CONNECTED = 'hangouts_connected'
|
||||
EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected'
|
||||
EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed'
|
||||
EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed'
|
||||
|
||||
CONF_CONVERSATION_ID = 'id'
|
||||
CONF_CONVERSATION_NAME = 'name'
|
||||
|
||||
SERVICE_SEND_MESSAGE = 'send_message'
|
||||
SERVICE_UPDATE = 'update'
|
||||
|
||||
|
||||
TARGETS_SCHEMA = vol.All(
|
||||
vol.Schema({
|
||||
vol.Exclusive(CONF_CONVERSATION_ID, 'id or name'): cv.string,
|
||||
vol.Exclusive(CONF_CONVERSATION_NAME, 'id or name'): cv.string
|
||||
}),
|
||||
cv.has_at_least_one_key(CONF_CONVERSATION_ID, CONF_CONVERSATION_NAME)
|
||||
)
|
||||
MESSAGE_SEGMENT_SCHEMA = vol.Schema({
|
||||
vol.Required('text'): cv.string,
|
||||
vol.Optional('is_bold'): cv.boolean,
|
||||
vol.Optional('is_italic'): cv.boolean,
|
||||
vol.Optional('is_strikethrough'): cv.boolean,
|
||||
vol.Optional('is_underline'): cv.boolean,
|
||||
vol.Optional('parse_str'): cv.boolean,
|
||||
vol.Optional('link_target'): cv.string
|
||||
})
|
||||
|
||||
MESSAGE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_TARGET): [TARGETS_SCHEMA],
|
||||
vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA]
|
||||
})
|
||||
|
||||
COMMAND_SCHEMA = vol.All(
|
||||
# Basic Schema
|
||||
vol.Schema({
|
||||
vol.Exclusive(CONF_WORD, 'trigger'): cv.string,
|
||||
vol.Exclusive(CONF_EXPRESSION, 'trigger'): cv.is_regex,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA]
|
||||
}),
|
||||
# Make sure it's either a word or an expression command
|
||||
cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA]
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
229
homeassistant/components/hangouts/hangouts_bot.py
Normal file
229
homeassistant/components/hangouts/hangouts_bot.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""The Hangouts Bot."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from homeassistant.helpers import dispatcher
|
||||
|
||||
from .const import (
|
||||
ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, CONF_EXPRESSION, CONF_NAME,
|
||||
CONF_WORD, DOMAIN, EVENT_HANGOUTS_COMMAND, EVENT_HANGOUTS_CONNECTED,
|
||||
EVENT_HANGOUTS_CONVERSATIONS_CHANGED, EVENT_HANGOUTS_DISCONNECTED)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HangoutsBot:
|
||||
"""The Hangouts Bot."""
|
||||
|
||||
def __init__(self, hass, refresh_token, commands):
|
||||
"""Set up the client."""
|
||||
self.hass = hass
|
||||
self._connected = False
|
||||
|
||||
self._refresh_token = refresh_token
|
||||
|
||||
self._commands = commands
|
||||
|
||||
self._word_commands = None
|
||||
self._expression_commands = None
|
||||
self._client = None
|
||||
self._user_list = None
|
||||
self._conversation_list = None
|
||||
|
||||
def _resolve_conversation_name(self, name):
|
||||
for conv in self._conversation_list.get_all():
|
||||
if conv.name == name:
|
||||
return conv
|
||||
return None
|
||||
|
||||
def async_update_conversation_commands(self, _):
|
||||
"""Refresh the commands for every conversation."""
|
||||
self._word_commands = {}
|
||||
self._expression_commands = {}
|
||||
|
||||
for command in self._commands:
|
||||
if command.get(CONF_CONVERSATIONS):
|
||||
conversations = []
|
||||
for conversation in command.get(CONF_CONVERSATIONS):
|
||||
if 'id' in conversation:
|
||||
conversations.append(conversation['id'])
|
||||
elif 'name' in conversation:
|
||||
conversations.append(self._resolve_conversation_name(
|
||||
conversation['name']).id_)
|
||||
command['_' + CONF_CONVERSATIONS] = conversations
|
||||
else:
|
||||
command['_' + CONF_CONVERSATIONS] = \
|
||||
[conv.id_ for conv in self._conversation_list.get_all()]
|
||||
|
||||
if command.get(CONF_WORD):
|
||||
for conv_id in command['_' + CONF_CONVERSATIONS]:
|
||||
if conv_id not in self._word_commands:
|
||||
self._word_commands[conv_id] = {}
|
||||
word = command[CONF_WORD].lower()
|
||||
self._word_commands[conv_id][word] = command
|
||||
elif command.get(CONF_EXPRESSION):
|
||||
command['_' + CONF_EXPRESSION] = re.compile(
|
||||
command.get(CONF_EXPRESSION))
|
||||
|
||||
for conv_id in command['_' + CONF_CONVERSATIONS]:
|
||||
if conv_id not in self._expression_commands:
|
||||
self._expression_commands[conv_id] = []
|
||||
self._expression_commands[conv_id].append(command)
|
||||
|
||||
try:
|
||||
self._conversation_list.on_event.remove_observer(
|
||||
self._handle_conversation_event)
|
||||
except ValueError:
|
||||
pass
|
||||
self._conversation_list.on_event.add_observer(
|
||||
self._handle_conversation_event)
|
||||
|
||||
def _handle_conversation_event(self, event):
|
||||
from hangups import ChatMessageEvent
|
||||
if event.__class__ is ChatMessageEvent:
|
||||
self._handle_conversation_message(
|
||||
event.conversation_id, event.user_id, event)
|
||||
|
||||
def _handle_conversation_message(self, conv_id, user_id, event):
|
||||
"""Handle a message sent to a conversation."""
|
||||
user = self._user_list.get_user(user_id)
|
||||
if user.is_self:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Handling message '%s' from %s",
|
||||
event.text, user.full_name)
|
||||
|
||||
event_data = None
|
||||
|
||||
pieces = event.text.split(' ')
|
||||
cmd = pieces[0].lower()
|
||||
command = self._word_commands.get(conv_id, {}).get(cmd)
|
||||
if command:
|
||||
event_data = {
|
||||
'command': command[CONF_NAME],
|
||||
'conversation_id': conv_id,
|
||||
'user_id': user_id,
|
||||
'user_name': user.full_name,
|
||||
'data': pieces[1:]
|
||||
}
|
||||
else:
|
||||
# After single-word commands, check all regex commands in the room
|
||||
for command in self._expression_commands.get(conv_id, []):
|
||||
match = command['_' + CONF_EXPRESSION].match(event.text)
|
||||
if not match:
|
||||
continue
|
||||
event_data = {
|
||||
'command': command[CONF_NAME],
|
||||
'conversation_id': conv_id,
|
||||
'user_id': user_id,
|
||||
'user_name': user.full_name,
|
||||
'data': match.groupdict()
|
||||
}
|
||||
if event_data is not None:
|
||||
self.hass.bus.fire(EVENT_HANGOUTS_COMMAND, event_data)
|
||||
|
||||
async def async_connect(self):
|
||||
"""Login to the Google Hangouts."""
|
||||
from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials
|
||||
|
||||
from hangups import Client
|
||||
from hangups import get_auth
|
||||
session = await self.hass.async_add_executor_job(
|
||||
get_auth, HangoutsCredentials(None, None, None),
|
||||
HangoutsRefreshToken(self._refresh_token))
|
||||
|
||||
self._client = Client(session)
|
||||
self._client.on_connect.add_observer(self._on_connect)
|
||||
self._client.on_disconnect.add_observer(self._on_disconnect)
|
||||
|
||||
self.hass.loop.create_task(self._client.connect())
|
||||
|
||||
def _on_connect(self):
|
||||
_LOGGER.debug('Connected!')
|
||||
self._connected = True
|
||||
dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED)
|
||||
|
||||
def _on_disconnect(self):
|
||||
"""Handle disconnecting."""
|
||||
_LOGGER.debug('Connection lost!')
|
||||
self._connected = False
|
||||
dispatcher.async_dispatcher_send(self.hass,
|
||||
EVENT_HANGOUTS_DISCONNECTED)
|
||||
|
||||
async def async_disconnect(self):
|
||||
"""Disconnect the client if it is connected."""
|
||||
if self._connected:
|
||||
await self._client.disconnect()
|
||||
|
||||
async def async_handle_hass_stop(self, _):
|
||||
"""Run once when Home Assistant stops."""
|
||||
await self.async_disconnect()
|
||||
|
||||
async def _async_send_message(self, message, targets):
|
||||
conversations = []
|
||||
for target in targets:
|
||||
conversation = None
|
||||
if 'id' in target:
|
||||
conversation = self._conversation_list.get(target['id'])
|
||||
elif 'name' in target:
|
||||
conversation = self._resolve_conversation_name(target['name'])
|
||||
if conversation is not None:
|
||||
conversations.append(conversation)
|
||||
|
||||
if not conversations:
|
||||
return False
|
||||
|
||||
from hangups import ChatMessageSegment, hangouts_pb2
|
||||
messages = []
|
||||
for segment in message:
|
||||
if 'parse_str' in segment and segment['parse_str']:
|
||||
messages.extend(ChatMessageSegment.from_str(segment['text']))
|
||||
else:
|
||||
if 'parse_str' in segment:
|
||||
del segment['parse_str']
|
||||
messages.append(ChatMessageSegment(**segment))
|
||||
messages.append(ChatMessageSegment('',
|
||||
segment_type=hangouts_pb2.
|
||||
SEGMENT_TYPE_LINE_BREAK))
|
||||
|
||||
if not messages:
|
||||
return False
|
||||
for conv in conversations:
|
||||
await conv.send_message(messages)
|
||||
|
||||
async def _async_list_conversations(self):
|
||||
import hangups
|
||||
self._user_list, self._conversation_list = \
|
||||
(await hangups.build_user_conversation_list(self._client))
|
||||
users = {}
|
||||
conversations = {}
|
||||
for user in self._user_list.get_all():
|
||||
users[str(user.id_.chat_id)] = {'full_name': user.full_name,
|
||||
'is_self': user.is_self}
|
||||
|
||||
for conv in self._conversation_list.get_all():
|
||||
users_in_conversation = {}
|
||||
for user in conv.users:
|
||||
users_in_conversation[str(user.id_.chat_id)] = \
|
||||
{'full_name': user.full_name, 'is_self': user.is_self}
|
||||
conversations[str(conv.id_)] = \
|
||||
{'name': conv.name, 'users': users_in_conversation}
|
||||
|
||||
self.hass.states.async_set("{}.users".format(DOMAIN),
|
||||
len(self._user_list.get_all()),
|
||||
attributes=users)
|
||||
self.hass.states.async_set("{}.conversations".format(DOMAIN),
|
||||
len(self._conversation_list.get_all()),
|
||||
attributes=conversations)
|
||||
dispatcher.async_dispatcher_send(self.hass,
|
||||
EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||
conversations)
|
||||
|
||||
async def async_handle_send_message(self, service):
|
||||
"""Handle the send_message service."""
|
||||
await self._async_send_message(service.data[ATTR_MESSAGE],
|
||||
service.data[ATTR_TARGET])
|
||||
|
||||
async def async_handle_update_users_and_conversations(self, _=None):
|
||||
"""Handle the update_users_and_conversations service."""
|
||||
await self._async_list_conversations()
|
81
homeassistant/components/hangouts/hangups_utils.py
Normal file
81
homeassistant/components/hangouts/hangups_utils.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Utils needed for Google Hangouts."""
|
||||
|
||||
from hangups import CredentialsPrompt, GoogleAuthError, RefreshTokenCache
|
||||
|
||||
|
||||
class Google2FAError(GoogleAuthError):
|
||||
"""A Google authentication request failed."""
|
||||
|
||||
|
||||
class HangoutsCredentials(CredentialsPrompt):
|
||||
"""Google account credentials.
|
||||
|
||||
This implementation gets the user data as params.
|
||||
"""
|
||||
|
||||
def __init__(self, email, password, pin=None):
|
||||
"""Google account credentials.
|
||||
|
||||
:param email: Google account email address.
|
||||
:param password: Google account password.
|
||||
:param pin: Google account verification code.
|
||||
"""
|
||||
self._email = email
|
||||
self._password = password
|
||||
self._pin = pin
|
||||
|
||||
def get_email(self):
|
||||
"""Return email.
|
||||
|
||||
:return: Google account email address.
|
||||
"""
|
||||
return self._email
|
||||
|
||||
def get_password(self):
|
||||
"""Return password.
|
||||
|
||||
:return: Google account password.
|
||||
"""
|
||||
return self._password
|
||||
|
||||
def get_verification_code(self):
|
||||
"""Return the verification code.
|
||||
|
||||
:return: Google account verification code.
|
||||
"""
|
||||
if self._pin is None:
|
||||
raise Google2FAError()
|
||||
return self._pin
|
||||
|
||||
def set_verification_code(self, pin):
|
||||
"""Set the verification code.
|
||||
|
||||
:param pin: Google account verification code.
|
||||
"""
|
||||
self._pin = pin
|
||||
|
||||
|
||||
class HangoutsRefreshToken(RefreshTokenCache):
|
||||
"""Memory-based cache for refresh token."""
|
||||
|
||||
def __init__(self, token):
|
||||
"""Memory-based cache for refresh token.
|
||||
|
||||
:param token: Initial refresh token.
|
||||
"""
|
||||
super().__init__("")
|
||||
self._token = token
|
||||
|
||||
def get(self):
|
||||
"""Get cached refresh token.
|
||||
|
||||
:return: Cached refresh token.
|
||||
"""
|
||||
return self._token
|
||||
|
||||
def set(self, refresh_token):
|
||||
"""Cache a refresh token.
|
||||
|
||||
:param refresh_token: Refresh token to cache.
|
||||
"""
|
||||
self._token = refresh_token
|
12
homeassistant/components/hangouts/services.yaml
Normal file
12
homeassistant/components/hangouts/services.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
update:
|
||||
description: Updates the list of users and conversations.
|
||||
|
||||
send_message:
|
||||
description: Send a notification to a specific target.
|
||||
fields:
|
||||
target:
|
||||
description: List of targets with id or name. [Required]
|
||||
example: '[{"id": "UgxrXzVrARmjx_C6AZx4AaABAagBo-6UCw"}, {"name": "Test Conversation"}]'
|
||||
message:
|
||||
description: List of message segments, only the "text" field is required in every segment. [Required]
|
||||
example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}, ...]'
|
31
homeassistant/components/hangouts/strings.json
Normal file
31
homeassistant/components/hangouts/strings.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Google Hangouts is already configured",
|
||||
"unknown": "Unknown error occurred."
|
||||
},
|
||||
"error": {
|
||||
"invalid_login": "Invalid Login, please try again.",
|
||||
"invalid_2fa": "Invalid 2 Factor Authorization, please try again.",
|
||||
"invalid_2fa_method": "Invalig 2FA Method (Verify on Phone)."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "E-Mail Address",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "",
|
||||
"title": "Google Hangouts Login"
|
||||
},
|
||||
"2fa": {
|
||||
"data": {
|
||||
"2fa": "2FA Pin"
|
||||
},
|
||||
"description": "",
|
||||
"title": "2-Factor-Authorization"
|
||||
}
|
||||
},
|
||||
"title": "Google Hangouts"
|
||||
}
|
||||
}
|
@ -57,7 +57,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Setup the HomeKit component."""
|
||||
"""Set up the HomeKit component."""
|
||||
_LOGGER.debug('Begin setup HomeKit')
|
||||
|
||||
conf = config[DOMAIN]
|
||||
@ -196,7 +196,7 @@ class HomeKit():
|
||||
self.driver = None
|
||||
|
||||
def setup(self):
|
||||
"""Setup bridge and accessory driver."""
|
||||
"""Set up bridge and accessory driver."""
|
||||
from .accessories import HomeBridge, HomeDriver
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
|
@ -27,17 +27,17 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debounce(func):
|
||||
"""Decorator function. Debounce callbacks form HomeKit."""
|
||||
"""Decorate function to debounce callbacks from HomeKit."""
|
||||
@ha_callback
|
||||
def call_later_listener(self, *args):
|
||||
"""Callback listener called from call_later."""
|
||||
"""Handle call_later callback."""
|
||||
debounce_params = self.debounce.pop(func.__name__, None)
|
||||
if debounce_params:
|
||||
self.hass.async_add_job(func, self, *debounce_params[1:])
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args):
|
||||
"""Wrapper starts async timer."""
|
||||
"""Start async timer."""
|
||||
debounce_params = self.debounce.pop(func.__name__, None)
|
||||
if debounce_params:
|
||||
debounce_params[0]() # remove listener
|
||||
@ -88,7 +88,7 @@ class HomeAccessory(Accessory):
|
||||
CHAR_STATUS_LOW_BATTERY, value=0)
|
||||
|
||||
async def run(self):
|
||||
"""Method called by accessory after driver is started.
|
||||
"""Handle accessory driver started event.
|
||||
|
||||
Run inside the HAP-python event loop.
|
||||
"""
|
||||
@ -100,7 +100,7 @@ class HomeAccessory(Accessory):
|
||||
@ha_callback
|
||||
def update_state_callback(self, entity_id=None, old_state=None,
|
||||
new_state=None):
|
||||
"""Callback from state change listener."""
|
||||
"""Handle state change listener callback."""
|
||||
_LOGGER.debug('New_state: %s', new_state)
|
||||
if new_state is None:
|
||||
return
|
||||
@ -131,7 +131,7 @@ class HomeAccessory(Accessory):
|
||||
hk_charging)
|
||||
|
||||
def update_state(self, new_state):
|
||||
"""Method called on state change to update HomeKit value.
|
||||
"""Handle state change to update HomeKit value.
|
||||
|
||||
Overridden by accessory types.
|
||||
"""
|
||||
|
@ -109,7 +109,7 @@ def show_setup_message(hass, pincode):
|
||||
"""Display persistent notification with setup information."""
|
||||
pin = pincode.decode()
|
||||
_LOGGER.info('Pincode: %s', pin)
|
||||
message = 'To setup Home Assistant in the Home App, enter the ' \
|
||||
message = 'To set up Home Assistant in the Home App, enter the ' \
|
||||
'following code:\n### {}'.format(pin)
|
||||
hass.components.persistent_notification.create(
|
||||
message, 'HomeKit Setup', HOMEKIT_NOTIFY_ID)
|
||||
|
@ -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.46']
|
||||
REQUIREMENTS = ['pyhomematic==0.1.47']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -48,6 +48,8 @@ ATTR_MESSAGE = 'message'
|
||||
ATTR_MODE = 'mode'
|
||||
ATTR_TIME = 'time'
|
||||
ATTR_UNIQUE_ID = 'unique_id'
|
||||
ATTR_PARAMSET_KEY = 'paramset_key'
|
||||
ATTR_PARAMSET = 'paramset'
|
||||
|
||||
EVENT_KEYPRESS = 'homematic.keypress'
|
||||
EVENT_IMPULSE = 'homematic.impulse'
|
||||
@ -58,6 +60,7 @@ SERVICE_RECONNECT = 'reconnect'
|
||||
SERVICE_SET_VARIABLE_VALUE = 'set_variable_value'
|
||||
SERVICE_SET_DEVICE_VALUE = 'set_device_value'
|
||||
SERVICE_SET_INSTALL_MODE = 'set_install_mode'
|
||||
SERVICE_PUT_PARAMSET = 'put_paramset'
|
||||
|
||||
HM_DEVICE_TYPES = {
|
||||
DISCOVER_SWITCHES: [
|
||||
@ -78,7 +81,7 @@ HM_DEVICE_TYPES = {
|
||||
DISCOVER_CLIMATE: [
|
||||
'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2',
|
||||
'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall',
|
||||
'ThermostatGroup'],
|
||||
'ThermostatGroup', 'IPThermostatWall230V'],
|
||||
DISCOVER_BINARY_SENSORS: [
|
||||
'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2',
|
||||
'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor',
|
||||
@ -103,7 +106,7 @@ HM_ATTRIBUTE_SUPPORT = {
|
||||
'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}],
|
||||
'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}],
|
||||
'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}],
|
||||
'RSSI_DEVICE': ['rssi', {}],
|
||||
'RSSI_PEER': ['rssi', {}],
|
||||
'VALVE_STATE': ['valve', {}],
|
||||
'BATTERY_STATE': ['battery', {}],
|
||||
'CONTROL_MODE': ['mode', {
|
||||
@ -232,6 +235,13 @@ SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({
|
||||
vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
|
||||
})
|
||||
|
||||
SCHEMA_SERVICE_PUT_PARAMSET = vol.Schema({
|
||||
vol.Required(ATTR_INTERFACE): cv.string,
|
||||
vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
|
||||
vol.Required(ATTR_PARAMSET_KEY): vol.All(cv.string, vol.Upper),
|
||||
vol.Required(ATTR_PARAMSET): dict,
|
||||
})
|
||||
|
||||
|
||||
@bind_hass
|
||||
def virtualkey(hass, address, channel, param, interface=None):
|
||||
@ -271,6 +281,19 @@ def set_device_value(hass, address, channel, param, value, interface=None):
|
||||
hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def put_paramset(hass, interface, address, paramset_key, paramset):
|
||||
"""Call putParamset XML-RPC method of supplied interface."""
|
||||
data = {
|
||||
ATTR_INTERFACE: interface,
|
||||
ATTR_ADDRESS: address,
|
||||
ATTR_PARAMSET_KEY: paramset_key,
|
||||
ATTR_PARAMSET: paramset,
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_PUT_PARAMSET, data)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def set_install_mode(hass, interface, mode=None, time=None, address=None):
|
||||
"""Call setInstallMode XML-RPC method of supplied interface."""
|
||||
@ -439,6 +462,26 @@ def setup(hass, config):
|
||||
DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode,
|
||||
schema=SCHEMA_SERVICE_SET_INSTALL_MODE)
|
||||
|
||||
def _service_put_paramset(service):
|
||||
"""Service to call the putParamset method on a HomeMatic connection."""
|
||||
interface = service.data.get(ATTR_INTERFACE)
|
||||
address = service.data.get(ATTR_ADDRESS)
|
||||
paramset_key = service.data.get(ATTR_PARAMSET_KEY)
|
||||
# When passing in the paramset from a YAML file we get an OrderedDict
|
||||
# here instead of a dict, so add this explicit cast.
|
||||
# The service schema makes sure that this cast works.
|
||||
paramset = dict(service.data.get(ATTR_PARAMSET))
|
||||
|
||||
_LOGGER.debug(
|
||||
"Calling putParamset: %s, %s, %s, %s",
|
||||
interface, address, paramset_key, paramset
|
||||
)
|
||||
homematic.putParamset(interface, address, paramset_key, paramset)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_PUT_PARAMSET, _service_put_paramset,
|
||||
schema=SCHEMA_SERVICE_PUT_PARAMSET)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@ -66,3 +66,20 @@ set_install_mode:
|
||||
address:
|
||||
description: (Optional) Address of homematic device or BidCoS-RF to learn
|
||||
example: LEQ3948571
|
||||
|
||||
put_paramset:
|
||||
description: Call to putParamset in the RPC XML interface
|
||||
fields:
|
||||
interface:
|
||||
description: The interfaces name from the config
|
||||
example: wireless
|
||||
address:
|
||||
description: Address of Homematic device
|
||||
example: LEQ3948571
|
||||
paramset_key:
|
||||
description: The paramset_key argument to putParamset
|
||||
example: MASTER
|
||||
paramset:
|
||||
description: A paramset dictionary
|
||||
example: '{"WEEK_PROGRAM_POINTER": 1}'
|
||||
|
||||
|
@ -1,24 +1,23 @@
|
||||
"""
|
||||
Support for HomematicIP components.
|
||||
Support for HomematicIP Cloud components.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME,
|
||||
CONF_ACCESSPOINT, CONF_AUTHTOKEN, CONF_NAME)
|
||||
# Loading the config flow file will register the flow
|
||||
from .config_flow import configured_haps
|
||||
from .hap import HomematicipHAP, HomematicipAuth # noqa: F401
|
||||
from .const import (
|
||||
CONF_ACCESSPOINT, CONF_AUTHTOKEN, DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID,
|
||||
HMIPC_NAME)
|
||||
from .device import HomematicipGenericDevice # noqa: F401
|
||||
from .hap import HomematicipAuth, HomematicipHAP # noqa: F401
|
||||
|
||||
REQUIREMENTS = ['homematicip==0.9.8']
|
||||
|
||||
@ -34,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the HomematicIP component."""
|
||||
"""Set up the HomematicIP Cloud component."""
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
accesspoints = config.get(DOMAIN, [])
|
||||
@ -54,7 +53,7 @@ async def async_setup(hass, config):
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up an accsspoint from a config entry."""
|
||||
"""Set up an access point from a config entry."""
|
||||
hap = HomematicipHAP(hass, entry)
|
||||
hapid = entry.data[HMIPC_HAPID].replace('-', '').upper()
|
||||
hass.data[DOMAIN][hapid] = hap
|
||||
|
@ -1,25 +1,25 @@
|
||||
"""Config flow to configure HomematicIP Cloud."""
|
||||
"""Config flow to configure the HomematicIP Cloud component."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN as HMIPC_DOMAIN, _LOGGER,
|
||||
HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME)
|
||||
from .const import DOMAIN as HMIPC_DOMAIN
|
||||
from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN
|
||||
from .const import _LOGGER
|
||||
from .hap import HomematicipAuth
|
||||
|
||||
|
||||
@callback
|
||||
def configured_haps(hass):
|
||||
"""Return a set of the configured accesspoints."""
|
||||
"""Return a set of the configured access points."""
|
||||
return set(entry.data[HMIPC_HAPID] for entry
|
||||
in hass.config_entries.async_entries(HMIPC_DOMAIN))
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(HMIPC_DOMAIN)
|
||||
class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler):
|
||||
"""Config flow HomematicIP Cloud."""
|
||||
"""Config flow for the HomematicIP Cloud component."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@ -44,28 +44,28 @@ class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler):
|
||||
self.auth = HomematicipAuth(self.hass, user_input)
|
||||
connected = await self.auth.async_setup()
|
||||
if connected:
|
||||
_LOGGER.info("Connection established")
|
||||
_LOGGER.info("Connection to HomematicIP Cloud established")
|
||||
return await self.async_step_link()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(HMIPC_HAPID): str,
|
||||
vol.Optional(HMIPC_PIN): str,
|
||||
vol.Optional(HMIPC_NAME): str,
|
||||
vol.Optional(HMIPC_PIN): str,
|
||||
}),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
async def async_step_link(self, user_input=None):
|
||||
"""Attempt to link with the HomematicIP Cloud accesspoint."""
|
||||
"""Attempt to link with the HomematicIP Cloud access point."""
|
||||
errors = {}
|
||||
|
||||
pressed = await self.auth.async_checkbutton()
|
||||
if pressed:
|
||||
authtoken = await self.auth.async_register()
|
||||
if authtoken:
|
||||
_LOGGER.info("Write config entry")
|
||||
_LOGGER.info("Write config entry for HomematicIP Cloud")
|
||||
return self.async_create_entry(
|
||||
title=self.auth.config.get(HMIPC_HAPID),
|
||||
data={
|
||||
@ -73,13 +73,13 @@ class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler):
|
||||
HMIPC_AUTHTOKEN: authtoken,
|
||||
HMIPC_NAME: self.auth.config.get(HMIPC_NAME)
|
||||
})
|
||||
return self.async_abort(reason='conection_aborted')
|
||||
return self.async_abort(reason='connection_aborted')
|
||||
errors['base'] = 'press_the_button'
|
||||
|
||||
return self.async_show_form(step_id='link', errors=errors)
|
||||
|
||||
async def async_step_import(self, import_info):
|
||||
"""Import a new bridge as a config entry."""
|
||||
"""Import a new access point as a config entry."""
|
||||
hapid = import_info[HMIPC_HAPID]
|
||||
authtoken = import_info[HMIPC_AUTHTOKEN]
|
||||
name = import_info[HMIPC_NAME]
|
||||
@ -88,13 +88,13 @@ class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler):
|
||||
if hapid in configured_haps(self.hass):
|
||||
return self.async_abort(reason='already_configured')
|
||||
|
||||
_LOGGER.info('Imported authentication for %s', hapid)
|
||||
_LOGGER.info("Imported authentication for %s", hapid)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=hapid,
|
||||
data={
|
||||
HMIPC_HAPID: hapid,
|
||||
HMIPC_AUTHTOKEN: authtoken,
|
||||
HMIPC_NAME: name
|
||||
HMIPC_HAPID: hapid,
|
||||
HMIPC_NAME: name,
|
||||
}
|
||||
)
|
||||
|
@ -14,7 +14,6 @@ COMPONENTS = [
|
||||
'switch',
|
||||
]
|
||||
|
||||
CONF_NAME = 'name'
|
||||
CONF_ACCESSPOINT = 'accesspoint'
|
||||
CONF_AUTHTOKEN = 'authtoken'
|
||||
|
||||
|
@ -1,25 +1,25 @@
|
||||
"""GenericDevice for the HomematicIP Cloud component."""
|
||||
"""Generic device for the HomematicIP Cloud component."""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_HOME_ID = 'home_id'
|
||||
ATTR_HOME_NAME = 'home_name'
|
||||
ATTR_CONNECTED = 'connected'
|
||||
ATTR_DEVICE_ID = 'device_id'
|
||||
ATTR_DEVICE_LABEL = 'device_label'
|
||||
ATTR_STATUS_UPDATE = 'status_update'
|
||||
ATTR_FIRMWARE_STATE = 'firmware_state'
|
||||
ATTR_UNREACHABLE = 'unreachable'
|
||||
ATTR_LOW_BATTERY = 'low_battery'
|
||||
ATTR_MODEL_TYPE = 'model_type'
|
||||
ATTR_GROUP_TYPE = 'group_type'
|
||||
ATTR_DEVICE_RSSI = 'device_rssi'
|
||||
ATTR_DUTY_CYCLE = 'duty_cycle'
|
||||
ATTR_CONNECTED = 'connected'
|
||||
ATTR_SABOTAGE = 'sabotage'
|
||||
ATTR_FIRMWARE_STATE = 'firmware_state'
|
||||
ATTR_GROUP_TYPE = 'group_type'
|
||||
ATTR_HOME_ID = 'home_id'
|
||||
ATTR_HOME_NAME = 'home_name'
|
||||
ATTR_LOW_BATTERY = 'low_battery'
|
||||
ATTR_MODEL_TYPE = 'model_type'
|
||||
ATTR_OPERATION_LOCK = 'operation_lock'
|
||||
ATTR_SABOTAGE = 'sabotage'
|
||||
ATTR_STATUS_UPDATE = 'status_update'
|
||||
ATTR_UNREACHABLE = 'unreachable'
|
||||
|
||||
|
||||
class HomematicipGenericDevice(Entity):
|
||||
@ -30,8 +30,7 @@ class HomematicipGenericDevice(Entity):
|
||||
self._home = home
|
||||
self._device = device
|
||||
self.post = post
|
||||
_LOGGER.info('Setting up %s (%s)', self.name,
|
||||
self._device.modelType)
|
||||
_LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
@ -39,16 +38,16 @@ class HomematicipGenericDevice(Entity):
|
||||
|
||||
def _device_changed(self, json, **kwargs):
|
||||
"""Handle device state changes."""
|
||||
_LOGGER.debug('Event %s (%s)', self.name, self._device.modelType)
|
||||
_LOGGER.debug("Event %s (%s)", self.name, self._device.modelType)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the generic device."""
|
||||
name = self._device.label
|
||||
if (self._home.name is not None and self._home.name != ''):
|
||||
if self._home.name is not None and self._home.name != '':
|
||||
name = "{} {}".format(self._home.name, name)
|
||||
if (self.post is not None and self.post != ''):
|
||||
if self.post is not None and self.post != '':
|
||||
name = "{} {}".format(name, self.post)
|
||||
return name
|
||||
|
||||
|
@ -1,21 +1,21 @@
|
||||
"""Errors for the HomematicIP component."""
|
||||
"""Errors for the HomematicIP Cloud component."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class HmipcException(HomeAssistantError):
|
||||
"""Base class for HomematicIP exceptions."""
|
||||
"""Base class for HomematicIP Cloud exceptions."""
|
||||
|
||||
|
||||
class HmipcConnectionError(HmipcException):
|
||||
"""Unable to connect to the HomematicIP cloud server."""
|
||||
"""Unable to connect to the HomematicIP Cloud server."""
|
||||
|
||||
|
||||
class HmipcConnectionWait(HmipcException):
|
||||
"""Wait for registration to the HomematicIP cloud server."""
|
||||
"""Wait for registration to the HomematicIP Cloud server."""
|
||||
|
||||
|
||||
class HmipcRegistrationFailed(HmipcException):
|
||||
"""Registration on HomematicIP cloud failed."""
|
||||
"""Registration on HomematicIP Cloud failed."""
|
||||
|
||||
|
||||
class HmipcPressButton(HmipcException):
|
||||
|
@ -1,14 +1,13 @@
|
||||
"""Accesspoint for the HomematicIP Cloud component."""
|
||||
"""Access point for the HomematicIP Cloud component."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME,
|
||||
COMPONENTS)
|
||||
COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN)
|
||||
from .errors import HmipcConnectionError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -74,10 +73,10 @@ class HomematicipAuth:
|
||||
|
||||
|
||||
class HomematicipHAP:
|
||||
"""Manages HomematicIP http and websocket connection."""
|
||||
"""Manages HomematicIP HTTP and WebSocket connection."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize HomematicIP cloud connection."""
|
||||
"""Initialize HomematicIP Cloud connection."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.home = None
|
||||
@ -100,7 +99,7 @@ class HomematicipHAP:
|
||||
except HmipcConnectionError:
|
||||
retry_delay = 2 ** min(tries + 1, 6)
|
||||
_LOGGER.error("Error connecting to HomematicIP with HAP %s. "
|
||||
"Retrying in %d seconds.",
|
||||
"Retrying in %d seconds",
|
||||
self.config_entry.data.get(HMIPC_HAPID), retry_delay)
|
||||
|
||||
async def retry_setup(_now):
|
||||
@ -113,7 +112,7 @@ class HomematicipHAP:
|
||||
|
||||
return False
|
||||
|
||||
_LOGGER.info('Connected to HomematicIP with HAP %s.',
|
||||
_LOGGER.info("Connected to HomematicIP with HAP %s",
|
||||
self.config_entry.data.get(HMIPC_HAPID))
|
||||
|
||||
for component in COMPONENTS:
|
||||
@ -127,7 +126,7 @@ class HomematicipHAP:
|
||||
def async_update(self, *args, **kwargs):
|
||||
"""Async update the home device.
|
||||
|
||||
Triggered when the hmip HOME_CHANGED event has fired.
|
||||
Triggered when the HMIP HOME_CHANGED event has fired.
|
||||
There are several occasions for this event to happen.
|
||||
We are only interested to check whether the access point
|
||||
is still connected. If not, device state changes cannot
|
||||
@ -147,7 +146,7 @@ class HomematicipHAP:
|
||||
job.add_done_callback(self.get_state_finished)
|
||||
|
||||
async def get_state(self):
|
||||
"""Update hmip state and tell hass."""
|
||||
"""Update HMIP state and tell Home Assistant."""
|
||||
await self.home.get_current_state()
|
||||
self.update_all()
|
||||
|
||||
@ -161,11 +160,11 @@ class HomematicipHAP:
|
||||
# Somehow connection could not recover. Will disconnect and
|
||||
# so reconnect loop is taking over.
|
||||
_LOGGER.error(
|
||||
"updating state after himp access point reconnect failed.")
|
||||
"Updating state after HMIP access point reconnect failed")
|
||||
self.hass.async_add_job(self.home.disable_events())
|
||||
|
||||
def set_all_to_unavailable(self):
|
||||
"""Set all devices to unavailable and tell Hass."""
|
||||
"""Set all devices to unavailable and tell Home Assistant."""
|
||||
for device in self.home.devices:
|
||||
device.unreach = True
|
||||
self.update_all()
|
||||
@ -190,7 +189,7 @@ class HomematicipHAP:
|
||||
return
|
||||
|
||||
async def async_connect(self):
|
||||
"""Start websocket connection."""
|
||||
"""Start WebSocket connection."""
|
||||
from homematicip.base.base_connection import HmipConnectionError
|
||||
|
||||
tries = 0
|
||||
@ -210,7 +209,7 @@ class HomematicipHAP:
|
||||
tries += 1
|
||||
retry_delay = 2 ** min(tries + 1, 6)
|
||||
_LOGGER.error("Error connecting to HomematicIP with HAP %s. "
|
||||
"Retrying in %d seconds.",
|
||||
"Retrying in %d seconds",
|
||||
self.config_entry.data.get(HMIPC_HAPID), retry_delay)
|
||||
try:
|
||||
self._retry_task = self.hass.async_add_job(asyncio.sleep(
|
||||
@ -227,7 +226,7 @@ class HomematicipHAP:
|
||||
if self._retry_task is not None:
|
||||
self._retry_task.cancel()
|
||||
self.home.disable_events()
|
||||
_LOGGER.info("Closed connection to HomematicIP cloud server.")
|
||||
_LOGGER.info("Closed connection to HomematicIP cloud server")
|
||||
for component in COMPONENTS:
|
||||
await self.hass.config_entries.async_forward_entry_unload(
|
||||
self.config_entry, component)
|
||||
|
@ -3,16 +3,16 @@
|
||||
"title": "HomematicIP Cloud",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Pick HomematicIP Accesspoint",
|
||||
"title": "Pick HomematicIP Access point",
|
||||
"data": {
|
||||
"hapid": "Accesspoint ID (SGTIN)",
|
||||
"hapid": "Access point ID (SGTIN)",
|
||||
"pin": "Pin Code (optional)",
|
||||
"name": "Name (optional, used as name prefix for all devices)"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"title": "Link Accesspoint",
|
||||
"description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n"
|
||||
"title": "Link Access point",
|
||||
"description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@ -23,8 +23,8 @@
|
||||
},
|
||||
"abort": {
|
||||
"unknown": "Unknown error occurred.",
|
||||
"conection_aborted": "Could not connect to HMIP server",
|
||||
"already_configured": "Accesspoint is already configured"
|
||||
"connection_aborted": "Could not connect to HMIP server",
|
||||
"already_configured": "Access point is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -232,7 +232,8 @@ class HomeAssistantHTTP:
|
||||
self.is_ban_enabled = is_ban_enabled
|
||||
self.ssl_profile = ssl_profile
|
||||
self._handler = None
|
||||
self.server = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
def register_view(self, view):
|
||||
"""Register a view with the WSGI server.
|
||||
@ -308,7 +309,7 @@ class HomeAssistantHTTP:
|
||||
self.app.router.add_route('GET', url_pattern, serve_file)
|
||||
|
||||
async def start(self):
|
||||
"""Start the WSGI server."""
|
||||
"""Start the aiohttp server."""
|
||||
# We misunderstood the startup signal. You're not allowed to change
|
||||
# anything during startup. Temp workaround.
|
||||
# pylint: disable=protected-access
|
||||
@ -321,7 +322,9 @@ class HomeAssistantHTTP:
|
||||
context = ssl_util.server_context_intermediate()
|
||||
else:
|
||||
context = ssl_util.server_context_modern()
|
||||
context.load_cert_chain(self.ssl_certificate, self.ssl_key)
|
||||
await self.hass.async_add_executor_job(
|
||||
context.load_cert_chain, self.ssl_certificate,
|
||||
self.ssl_key)
|
||||
except OSError as error:
|
||||
_LOGGER.error("Could not read SSL certificate from %s: %s",
|
||||
self.ssl_certificate, error)
|
||||
@ -329,7 +332,9 @@ class HomeAssistantHTTP:
|
||||
|
||||
if self.ssl_peer_certificate:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
context.load_verify_locations(cafile=self.ssl_peer_certificate)
|
||||
await self.hass.async_add_executor_job(
|
||||
context.load_verify_locations,
|
||||
self.ssl_peer_certificate)
|
||||
|
||||
else:
|
||||
context = None
|
||||
@ -340,21 +345,17 @@ class HomeAssistantHTTP:
|
||||
# To work around this we now prevent the router from getting frozen
|
||||
self.app._router.freeze = lambda: None
|
||||
|
||||
self._handler = self.app.make_handler(loop=self.hass.loop)
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, self.server_host,
|
||||
self.server_port, ssl_context=context)
|
||||
try:
|
||||
self.server = await self.hass.loop.create_server(
|
||||
self._handler, self.server_host, self.server_port, ssl=context)
|
||||
await self.site.start()
|
||||
except OSError as error:
|
||||
_LOGGER.error("Failed to create HTTP server at port %d: %s",
|
||||
self.server_port, error)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the WSGI server."""
|
||||
if self.server:
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
await self.app.shutdown()
|
||||
if self._handler:
|
||||
await self._handler.shutdown(10)
|
||||
await self.app.cleanup()
|
||||
"""Stop the aiohttp server."""
|
||||
await self.site.stop()
|
||||
await self.runner.cleanup()
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user