Cloud: allow managing Alexa entities via UI (#24522)

* Clean up Alexa config

* Cloud: Manage Alexa entities via UI

* Add tests for new cloud APIs
pull/24544/head
Paulus Schoutsen 2019-06-13 11:58:08 -07:00 committed by GitHub
parent 08591dae0e
commit 6c5124e12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 322 additions and 106 deletions

View File

@ -1,13 +1,33 @@
"""Config helpers for Alexa."""
class Config:
class AbstractConfig:
"""Hold the configuration for Alexa."""
def __init__(self, endpoint, async_get_access_token, should_expose,
entity_config=None):
"""Initialize the configuration."""
self.endpoint = endpoint
self.async_get_access_token = async_get_access_token
self.should_expose = should_expose
self.entity_config = entity_config or {}
@property
def supports_auth(self):
"""Return if config supports auth."""
return False
@property
def endpoint(self):
"""Endpoint for report state."""
return None
@property
def entity_config(self):
"""Return entity config."""
return {}
def should_expose(self, entity_id):
"""If an entity should be exposed."""
# pylint: disable=no-self-use
return False
async def async_get_access_token(self):
"""Get an access token."""
raise NotImplementedError
async def async_accept_grant(self, code):
"""Accept a grant."""
raise NotImplementedError

View File

@ -48,8 +48,6 @@ API_CHANGE = 'change'
CONF_DESCRIPTION = 'description'
CONF_DISPLAY_CATEGORIES = 'display_categories'
AUTH_KEY = "alexa.smart_home.auth"
API_TEMP_UNITS = {
TEMP_FAHRENHEIT: 'FAHRENHEIT',
TEMP_CELSIUS: 'CELSIUS',

View File

@ -31,7 +31,6 @@ from homeassistant.components import cover, fan, group, light, media_player
from homeassistant.util.temperature import convert as convert_temperature
from .const import (
AUTH_KEY,
API_TEMP_UNITS,
API_THERMOSTAT_MODES,
Cause,
@ -86,8 +85,8 @@ async def async_api_accept_grant(hass, config, directive, context):
auth_code = directive.payload['grant']['code']
_LOGGER.debug("AcceptGrant code: %s", auth_code)
if AUTH_KEY in hass.data:
await hass.data[AUTH_KEY].async_do_auth(auth_code)
if config.supports_auth:
await config.async_accept_grant(auth_code)
await async_enable_proactive_mode(hass, config)
return directive.response(

View File

@ -5,9 +5,8 @@ from homeassistant import core
from homeassistant.components.http.view import HomeAssistantView
from .auth import Auth
from .config import Config
from .config import AbstractConfig
from .const import (
AUTH_KEY,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_ENDPOINT,
@ -21,6 +20,47 @@ _LOGGER = logging.getLogger(__name__)
SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home'
class AlexaConfig(AbstractConfig):
"""Alexa config."""
def __init__(self, hass, config):
"""Initialize Alexa config."""
self._config = config
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
self._auth = Auth(hass, config[CONF_CLIENT_ID],
config[CONF_CLIENT_SECRET])
else:
self._auth = None
@property
def supports_auth(self):
"""Return if config supports auth."""
return self._auth is not None
@property
def endpoint(self):
"""Endpoint for report state."""
return self._config.get(CONF_ENDPOINT)
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG, {})
def should_expose(self, entity_id):
"""If an entity should be exposed."""
return self._config[CONF_FILTER](entity_id)
async def async_get_access_token(self):
"""Get an access token."""
return await self._auth.async_get_access_token()
async def async_accept_grant(self, code):
"""Accept a grant."""
return await self._auth.async_do_auth(code)
async def async_setup(hass, config):
"""Activate Smart Home functionality of Alexa component.
@ -30,23 +70,10 @@ async def async_setup(hass, config):
Even if that's disabled, the functionality in this module may still be used
by the cloud component which will call async_handle_message directly.
"""
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID],
config[CONF_CLIENT_SECRET])
async_get_access_token = \
hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \
else None
smart_home_config = Config(
endpoint=config.get(CONF_ENDPOINT),
async_get_access_token=async_get_access_token,
should_expose=config[CONF_FILTER],
entity_config=config.get(CONF_ENTITY_CONFIG),
)
smart_home_config = AlexaConfig(hass, config)
hass.http.register_view(SmartHomeView(smart_home_config))
if AUTH_KEY in hass.data:
if smart_home_config.supports_auth:
await async_enable_proactive_mode(hass, smart_home_config)

View File

@ -61,7 +61,6 @@ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
vol.In([MODE_DEV, MODE_PROD]),
# Change to optional when we include real servers
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
vol.Optional(CONF_USER_POOL_ID): str,
vol.Optional(CONF_REGION): str,

View File

@ -26,6 +26,38 @@ from .const import (
from .prefs import CloudPreferences
class AlexaConfig(alexa_config.AbstractConfig):
"""Alexa Configuration."""
def __init__(self, config, prefs):
"""Initialize the Alexa config."""
self._config = config
self._prefs = prefs
@property
def endpoint(self):
"""Endpoint for report state."""
return None
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG, {})
def should_expose(self, entity_id):
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not self._config[CONF_FILTER].empty_filter:
return self._config[CONF_FILTER](entity_id)
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
class CloudClient(Interface):
"""Interface class for Home Assistant Cloud."""
@ -36,10 +68,10 @@ class CloudClient(Interface):
self._hass = hass
self._prefs = prefs
self._websession = websession
self._alexa_user_config = alexa_cfg
self._google_user_config = google_config
self.google_user_config = google_config
self.alexa_user_config = alexa_cfg
self._alexa_config = None
self.alexa_config = AlexaConfig(alexa_cfg, prefs)
self._google_config = None
@property
@ -77,26 +109,11 @@ class CloudClient(Interface):
"""Return true if we want start a remote connection."""
return self._prefs.remote_enabled
@property
def alexa_config(self) -> alexa_config.Config:
"""Return Alexa config."""
if not self._alexa_config:
alexa_conf = self._alexa_user_config
self._alexa_config = alexa_config.Config(
endpoint=None,
async_get_access_token=None,
should_expose=alexa_conf[CONF_FILTER],
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
)
return self._alexa_config
@property
def google_config(self) -> ga_h.Config:
"""Return Google config."""
if not self._google_config:
google_conf = self._google_user_config
google_conf = self.google_user_config
def should_expose(entity):
"""If an entity should be exposed."""
@ -134,14 +151,8 @@ class CloudClient(Interface):
return self._google_config
@property
def google_user_config(self) -> Dict[str, Any]:
"""Return google action user config."""
return self._google_user_config
async def cleanups(self) -> None:
"""Cleanup some stuff after logout."""
self._alexa_config = None
self._google_config = None
@callback

View File

@ -9,6 +9,7 @@ PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin'
PREF_CLOUDHOOKS = 'cloudhooks'
PREF_CLOUD_USER = 'cloud_user'
PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs'
PREF_ALEXA_ENTITY_CONFIGS = 'alexa_entity_configs'
PREF_OVERRIDE_NAME = 'override_name'
PREF_DISABLE_2FA = 'disable_2fa'
PREF_ALIASES = 'aliases'

View File

@ -90,6 +90,9 @@ async def async_setup(hass):
hass.components.websocket_api.async_register_command(
google_assistant_update)
hass.components.websocket_api.async_register_command(alexa_list)
hass.components.websocket_api.async_register_command(alexa_update)
hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView)
hass.http.register_view(CloudLogoutView)
@ -420,7 +423,7 @@ def _account_data(cloud):
'cloud': cloud.iot.state,
'prefs': client.prefs.as_dict(),
'google_entities': client.google_user_config['filter'].config,
'alexa_entities': client.alexa_config.should_expose.config,
'alexa_entities': client.alexa_user_config['filter'].config,
'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS),
'remote_domain': remote.instance_domain,
'remote_connected': remote.is_connected,
@ -508,3 +511,52 @@ async def google_assistant_update(hass, connection, msg):
connection.send_result(
msg['id'],
cloud.client.prefs.google_entity_configs.get(msg['entity_id']))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/alexa/entities'
})
async def alexa_list(hass, connection, msg):
"""List all alexa entities."""
cloud = hass.data[DOMAIN]
entities = alexa_entities.async_get_entities(
hass, cloud.client.alexa_config
)
result = []
for entity in entities:
result.append({
'entity_id': entity.entity_id,
'display_categories': entity.default_display_categories(),
'interfaces': [ifc.name() for ifc in entity.interfaces()],
})
connection.send_result(msg['id'], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/alexa/entities/update',
'entity_id': str,
vol.Optional('should_expose'): bool,
})
async def alexa_update(hass, connection, msg):
"""Update alexa entity config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop('type')
changes.pop('id')
await cloud.client.prefs.async_update_alexa_entity_config(**changes)
connection.send_result(
msg['id'],
cloud.client.prefs.alexa_entity_configs.get(msg['entity_id']))

View File

@ -5,7 +5,7 @@ from .const import (
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA,
PREF_ALIASES, PREF_SHOULD_EXPOSE,
PREF_ALIASES, PREF_SHOULD_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS,
InvalidTrustedNetworks, InvalidTrustedProxies)
STORAGE_KEY = DOMAIN
@ -33,6 +33,7 @@ class CloudPreferences:
PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_CLOUDHOOKS: {},
PREF_CLOUD_USER: None,
}
@ -42,7 +43,8 @@ class CloudPreferences:
async def async_update(self, *, google_enabled=_UNDEF,
alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF,
cloud_user=_UNDEF, google_entity_configs=_UNDEF):
cloud_user=_UNDEF, google_entity_configs=_UNDEF,
alexa_entity_configs=_UNDEF):
"""Update user preferences."""
for key, value in (
(PREF_ENABLE_GOOGLE, google_enabled),
@ -52,6 +54,7 @@ class CloudPreferences:
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user),
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
):
if value is not _UNDEF:
self._prefs[key] = value
@ -95,6 +98,33 @@ class CloudPreferences:
}
await self.async_update(google_entity_configs=updated_entities)
async def async_update_alexa_entity_config(
self, *, entity_id, should_expose=_UNDEF):
"""Update config for an Alexa entity."""
entities = self.alexa_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in (
(PREF_SHOULD_EXPOSE, should_expose),
):
if value is not _UNDEF:
changes[key] = value
if not changes:
return
updated_entity = {
**entity,
**changes,
}
updated_entities = {
**entities,
entity_id: updated_entity,
}
await self.async_update(alexa_entity_configs=updated_entities)
def as_dict(self):
"""Return dictionary version."""
return {
@ -103,6 +133,7 @@ class CloudPreferences:
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_CLOUD_USER: self.cloud_user,
}
@ -140,6 +171,11 @@ class CloudPreferences:
"""Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property
def alexa_entity_configs(self):
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
@property
def cloudhooks(self):
"""Return the published cloud webhooks."""

View File

@ -10,15 +10,35 @@ TEST_URL = "https://api.amazonalexa.com/v3/events"
TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
async def get_access_token():
"""Return a test access token."""
return "thisisnotanacesstoken"
class MockConfig(config.AbstractConfig):
"""Mock Alexa config."""
entity_config = {}
@property
def supports_auth(self):
"""Return if config supports auth."""
return True
@property
def endpoint(self):
"""Endpoint for report state."""
return TEST_URL
def should_expose(self, entity_id):
"""If an entity should be exposed."""
return True
async def async_get_access_token(self):
"""Get an access token."""
return "thisisnotanacesstoken"
async def async_accept_grant(self, code):
"""Accept a grant."""
pass
DEFAULT_CONFIG = config.Config(
endpoint=TEST_URL,
async_get_access_token=get_access_token,
should_expose=lambda entity_id: True)
DEFAULT_CONFIG = MockConfig()
def get_new_request(namespace, name, endpoint=None):

View File

@ -4,7 +4,6 @@ import pytest
from homeassistant.core import Context, callback
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.components.alexa import (
config,
smart_home,
messages,
)
@ -14,6 +13,7 @@ from tests.common import async_mock_service
from . import (
get_new_request,
MockConfig,
DEFAULT_CONFIG,
assert_request_calls_service,
assert_request_fails,
@ -1012,15 +1012,13 @@ async def test_exclude_filters(hass):
hass.states.async_set(
'cover.deny', 'off', {'friendly_name': "Blocked cover"})
alexa_config = config.Config(
endpoint=None,
async_get_access_token=None,
should_expose=entityfilter.generate_filter(
include_domains=[],
include_entities=[],
exclude_domains=['script'],
exclude_entities=['cover.deny'],
))
alexa_config = MockConfig()
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=[],
include_entities=[],
exclude_domains=['script'],
exclude_entities=['cover.deny'],
)
msg = await smart_home.async_handle_message(hass, alexa_config, request)
await hass.async_block_till_done()
@ -1047,15 +1045,13 @@ async def test_include_filters(hass):
hass.states.async_set(
'group.allow', 'off', {'friendly_name': "Allowed group"})
alexa_config = config.Config(
endpoint=None,
async_get_access_token=None,
should_expose=entityfilter.generate_filter(
include_domains=['automation', 'group'],
include_entities=['script.deny'],
exclude_domains=[],
exclude_entities=[],
))
alexa_config = MockConfig()
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=['automation', 'group'],
include_entities=['script.deny'],
exclude_domains=[],
exclude_entities=[],
)
msg = await smart_home.async_handle_message(hass, alexa_config, request)
await hass.async_block_till_done()
@ -1076,15 +1072,13 @@ async def test_never_exposed_entities(hass):
hass.states.async_set(
'group.allow', 'off', {'friendly_name': "Allowed group"})
alexa_config = config.Config(
endpoint=None,
async_get_access_token=None,
should_expose=entityfilter.generate_filter(
include_domains=['group'],
include_entities=[],
exclude_domains=[],
exclude_entities=[],
))
alexa_config = MockConfig()
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=['group'],
include_entities=[],
exclude_domains=[],
exclude_entities=[],
)
msg = await smart_home.async_handle_message(hass, alexa_config, request)
await hass.async_block_till_done()
@ -1161,18 +1155,14 @@ async def test_entity_config(hass):
hass.states.async_set(
'light.test_1', 'on', {'friendly_name': "Test light 1"})
alexa_config = config.Config(
endpoint=None,
async_get_access_token=None,
should_expose=lambda entity_id: True,
entity_config={
'light.test_1': {
'name': 'Config name',
'display_categories': 'SWITCH',
'description': 'Config description'
}
alexa_config = MockConfig()
alexa_config.entity_config = {
'light.test_1': {
'name': 'Config name',
'display_categories': 'SWITCH',
'description': 'Config description'
}
)
}
msg = await smart_home.async_handle_message(
hass, alexa_config, request)

View File

@ -7,7 +7,8 @@ import pytest
from homeassistant.core import State
from homeassistant.setup import async_setup_component
from homeassistant.components.cloud import DOMAIN
from homeassistant.components.cloud import (
DOMAIN, ALEXA_SCHEMA, prefs, client)
from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
from tests.components.alexa import test_smart_home as test_alexa
@ -251,3 +252,20 @@ async def test_google_config_should_2fa(
)
assert not cloud_client.google_config.should_2fa(state)
async def test_alexa_config_expose_entity_prefs(hass):
"""Test Alexa config should expose using prefs."""
cloud_prefs = prefs.CloudPreferences(hass)
await cloud_prefs.async_initialize()
entity_conf = {
'should_expose': False
}
await cloud_prefs.async_update(alexa_entity_configs={
'light.kitchen': entity_conf
})
conf = client.AlexaConfig(ALEXA_SCHEMA({}), cloud_prefs)
assert not conf.should_expose('light.kitchen')
entity_conf['should_expose'] = True
assert conf.should_expose('light.kitchen')

View File

@ -15,6 +15,7 @@ from homeassistant.components.cloud.const import (
DOMAIN)
from homeassistant.components.google_assistant.helpers import (
GoogleEntity, Config)
from homeassistant.components.alexa.entities import LightCapabilities
from tests.common import mock_coro
@ -361,6 +362,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
'google_enabled': True,
'google_entity_configs': {},
'google_secure_devices_pin': None,
'alexa_entity_configs': {},
'remote_enabled': False,
},
'alexa_entities': {
@ -800,3 +802,46 @@ async def test_enabling_remote_trusted_proxies_local6(
'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.'
assert len(mock_connect.mock_calls) == 0
async def test_list_alexa_entities(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test that we can list Alexa entities."""
client = await hass_ws_client(hass)
entity = LightCapabilities(hass, MagicMock(entity_config={}), State(
'light.kitchen', 'on'
))
with patch('homeassistant.components.alexa.entities'
'.async_get_entities', return_value=[entity]):
await client.send_json({
'id': 5,
'type': 'cloud/alexa/entities',
})
response = await client.receive_json()
assert response['success']
assert len(response['result']) == 1
assert response['result'][0] == {
'entity_id': 'light.kitchen',
'display_categories': ['LIGHT'],
'interfaces': ['Alexa.PowerController', 'Alexa.EndpointHealth'],
}
async def test_update_alexa_entity(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test that we can update config of an Alexa entity."""
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
'type': 'cloud/alexa/entities/update',
'entity_id': 'light.kitchen',
'should_expose': False,
})
response = await client.receive_json()
assert response['success']
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs['light.kitchen'] == {
'should_expose': False,
}