From 4bd6776443d6a56e20ac290d7b692878a2d49577 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Mar 2018 12:13:52 -0700 Subject: [PATCH] Google assistant sync (#13392) * Add Google Assistant Sync API * Update const.py * Async/await --- homeassistant/components/cloud/__init__.py | 6 +- homeassistant/components/cloud/const.py | 4 +- homeassistant/components/cloud/http_api.py | 66 ++++++++++++++-------- tests/components/cloud/test_http_api.py | 19 +++++++ tests/components/cloud/test_init.py | 2 + 5 files changed, 71 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index adf0b8f51b6..e73d043d366 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -37,6 +37,7 @@ CONF_FILTER = 'filter' CONF_GOOGLE_ACTIONS = 'google_actions' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' +CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -75,6 +76,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, + vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -110,7 +112,7 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, - relayer=None): + relayer=None, google_actions_sync_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -128,6 +130,7 @@ class Cloud: self.user_pool_id = user_pool_id self.region = region self.relayer = relayer + self.google_actions_sync_url = google_actions_sync_url else: info = SERVERS[mode] @@ -136,6 +139,7 @@ class Cloud: self.user_pool_id = info['user_pool_id'] self.region = info['region'] self.relayer = info['relayer'] + self.google_actions_sync_url = info['google_actions_sync_url'] @property def is_logged_in(self): diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 99075d3d02d..82128206d47 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -8,7 +8,9 @@ SERVERS = { 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', 'user_pool_id': 'us-east-1_87ll5WOP8', 'region': 'us-east-1', - 'relayer': 'wss://cloud.hass.io:8000/websocket' + 'relayer': 'wss://cloud.hass.io:8000/websocket', + 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' + 'amazonaws.com/prod/smart_home_sync'), } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 3065de24180..a4b3b59f333 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -16,9 +16,9 @@ from .const import DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Initialize the HTTP API.""" + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) @@ -38,12 +38,11 @@ _CLOUD_ERRORS = { def _handle_cloud_errors(handler): """Handle auth errors.""" - @asyncio.coroutine @wraps(handler) - def error_handler(view, request, *args, **kwargs): + async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" try: - result = yield from handler(view, request, *args, **kwargs) + result = await handler(view, request, *args, **kwargs) return result except (auth_api.CloudError, asyncio.TimeoutError) as err: @@ -57,6 +56,31 @@ def _handle_cloud_errors(handler): return error_handler +class GoogleActionsSyncView(HomeAssistantView): + """Trigger a Google Actions Smart Home Sync.""" + + url = '/api/cloud/google_actions/sync' + name = 'api:cloud:google_actions/sync' + + @_handle_cloud_errors + async def post(self, request): + """Trigger a Google Actions sync.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job(auth_api.check_token, cloud) + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = await websession.post( + cloud.google_actions_sync_url, headers={ + 'authorization': cloud.id_token + }) + + return self.json({}, status_code=req.status) + + class CloudLoginView(HomeAssistantView): """Login to Home Assistant cloud.""" @@ -68,19 +92,18 @@ class CloudLoginView(HomeAssistantView): vol.Required('email'): str, vol.Required('password'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle login request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth_api.login, cloud, data['email'], - data['password']) + await hass.async_add_job(auth_api.login, cloud, data['email'], + data['password']) hass.async_add_job(cloud.iot.connect) # Allow cloud to start connecting. - yield from asyncio.sleep(0, loop=hass.loop) + await asyncio.sleep(0, loop=hass.loop) return self.json(_account_data(cloud)) @@ -91,14 +114,13 @@ class CloudLogoutView(HomeAssistantView): name = 'api:cloud:logout' @_handle_cloud_errors - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Handle logout request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from cloud.logout() + await cloud.logout() return self.json_message('ok') @@ -109,8 +131,7 @@ class CloudAccountView(HomeAssistantView): url = '/api/cloud/account' name = 'api:cloud:account' - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Get account info.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] @@ -132,14 +153,13 @@ class CloudRegisterView(HomeAssistantView): vol.Required('email'): str, vol.Required('password'): vol.All(str, vol.Length(min=6)), })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle registration request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.register, cloud, data['email'], data['password']) return self.json_message('ok') @@ -155,14 +175,13 @@ class CloudResendConfirmView(HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('email'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle resending confirm email code request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.resend_email_confirm, cloud, data['email']) return self.json_message('ok') @@ -178,14 +197,13 @@ class CloudForgotPasswordView(HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('email'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle forgot password request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.forgot_password, cloud, data['email']) return self.json_message('ok') diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 1ed3d1b4744..55c6290c158 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -11,6 +11,9 @@ from homeassistant.components.cloud import DOMAIN, auth_api, iot from tests.common import mock_coro +GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync' + + @pytest.fixture def cloud_client(hass, aiohttp_client): """Fixture that can fetch from the cloud client.""" @@ -23,6 +26,7 @@ def cloud_client(hass, aiohttp_client): 'user_pool_id': 'user_pool_id', 'region': 'region', 'relayer': 'relayer', + 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, } })) hass.data['cloud']._decode_claims = \ @@ -38,6 +42,21 @@ def mock_cognito(): yield mock_cog() +async def test_google_actions_sync(mock_cognito, cloud_client, aioclient_mock): + """Test syncing Google Actions.""" + aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL) + req = await cloud_client.post('/api/cloud/google_actions/sync') + assert req.status == 200 + + +async def test_google_actions_sync_fails(mock_cognito, cloud_client, + aioclient_mock): + """Test syncing Google Actions gone bad.""" + aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL, status=403) + req = await cloud_client.post('/api/cloud/google_actions/sync') + assert req.status == 403 + + @asyncio.coroutine def test_account_view_no_account(cloud_client): """Test fetching account if no account available.""" diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 70990519a0b..91f8ab8316d 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -29,6 +29,7 @@ def test_constructor_loads_info_from_constant(): 'user_pool_id': 'test-user_pool_id', 'region': 'test-region', 'relayer': 'test-relayer', + 'google_actions_sync_url': 'test-google_actions_sync_url', } }), patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', return_value=mock_coro(True)): @@ -43,6 +44,7 @@ def test_constructor_loads_info_from_constant(): assert cl.user_pool_id == 'test-user_pool_id' assert cl.region == 'test-region' assert cl.relayer == 'test-relayer' + assert cl.google_actions_sync_url == 'test-google_actions_sync_url' @asyncio.coroutine