diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index eab725c4653..176c286ebc3 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1529,3 +1529,8 @@ async def async_api_reportstate(hass, config, request, context, entity): name='StateReport', context={'properties': properties} ) + + +def turned_off_response(message): + """Return a device turned off response.""" + return api_error(message[API_DIRECTIVE], error_type='BRIDGE_UNREACHABLE') diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 74625f7363b..33a939bf9d0 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -27,8 +27,13 @@ from . import http_api, iot, auth_api from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_ENABLE_ALEXA = 'alexa_enabled' +STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) +_UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -124,11 +129,13 @@ class Cloud: self.alexa_config = alexa self._google_actions = google_actions self._gactions_config = None + self._prefs = None self.jwt_keyset = None self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -193,6 +200,16 @@ class Cloud: return self._gactions_config + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[STORAGE_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[STORAGE_ENABLE_GOOGLE] + def path(self, *parts): """Get config path inside cloud dir. @@ -231,10 +248,23 @@ class Cloud: 'refresh_token': self.refresh_token, }, indent=4)) - @asyncio.coroutine - def async_start(self, _): + async def async_start(self, _): """Start the cloud component.""" - success = yield from self._fetch_jwt_keyset() + prefs = await self._store.async_load() + if prefs is None: + prefs = {} + if self.mode not in prefs: + # Default to True if already logged in to make this not a + # breaking change. + enabled = await self.hass.async_add_executor_job( + os.path.isfile, self.user_info_path) + prefs = { + STORAGE_ENABLE_ALEXA: enabled, + STORAGE_ENABLE_GOOGLE: enabled, + } + self._prefs = prefs + + success = await self._fetch_jwt_keyset() # Fetching keyset can fail if internet is not up yet. if not success: @@ -255,7 +285,7 @@ class Cloud: with open(user_info, 'rt') as file: return json.loads(file.read()) - info = yield from self.hass.async_add_job(load_config) + info = await self.hass.async_add_job(load_config) if info is None: return @@ -274,6 +304,15 @@ class Cloud: self.hass.add_job(self.iot.connect()) + async def update_preferences(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF): + """Update user preferences.""" + if google_enabled is not _UNDEF: + self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled + if alexa_enabled is not _UNDEF: + self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled + await self._store.async_save(self._prefs) + @asyncio.coroutine def _fetch_jwt_keyset(self): """Fetch the JWT keyset for the Cognito instance.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 24617bb1f17..c81ec38bace 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -25,6 +25,14 @@ SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' +SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE_PREFS, + vol.Optional('google_enabled'): bool, + vol.Optional('alexa_enabled'): bool, +}) + + WS_TYPE_SUBSCRIPTION = 'cloud/subscription' SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SUBSCRIPTION, @@ -41,6 +49,10 @@ async def async_setup(hass): WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE_PREFS, websocket_update_prefs, + SCHEMA_WS_UPDATE_PREFS + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -245,6 +257,26 @@ async def websocket_subscription(hass, connection, msg): msg['id'], 'request_failed', 'Failed to request subscription')) +@websocket_api.async_response +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + if not cloud.is_logged_in: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + changes = dict(msg) + changes.pop('id') + changes.pop('type') + await cloud.update_preferences(**changes) + + connection.send_message_outside(websocket_api.result_message( + msg['id'], {'success': True})) + + def _account_data(cloud): """Generate the auth data JSON response.""" if not cloud.is_logged_in: @@ -259,4 +291,6 @@ def _account_data(cloud): 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, + 'google_enabled': cloud.google_enabled, + 'alexa_enabled': cloud.alexa_enabled, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index f4ce7bb3d1a..fd525ed33a8 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -227,6 +227,9 @@ def async_handle_message(hass, cloud, handler_name, payload): @asyncio.coroutine def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" + if not cloud.alexa_enabled: + return alexa.turned_off_response(payload) + result = yield from alexa.async_handle_message( hass, cloud.alexa_config, payload) return result @@ -236,6 +239,9 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" + if not cloud.google_enabled: + return ga.turned_off_response(payload) + result = yield from ga.async_handle_message( hass, cloud.gactions_config, payload) return result diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 675e86f9d39..1cb4bf4cb32 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -324,3 +324,11 @@ async def handle_devices_execute(hass, config, payload): }) return {'commands': final_results} + + +def turned_off_response(message): + """Return a device turned off response.""" + return { + 'requestId': message.get('requestId'), + 'payload': {'errorCode': 'deviceTurnedOff'} + } diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 7a4e9f2950e..108e5c45137 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1 +1,32 @@ """Tests for the cloud component.""" +from unittest.mock import patch +from homeassistant.setup import async_setup_component +from homeassistant.components import cloud + +from jose import jwt + +from tests.common import mock_coro + + +def mock_cloud(hass, config={}): + """Mock cloud.""" + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): + assert hass.loop.run_until_complete(async_setup_component( + hass, cloud.DOMAIN, { + 'cloud': config + })) + + hass.data[cloud.DOMAIN]._decode_claims = \ + lambda token: jwt.get_unverified_claims(token) + + +def mock_cloud_prefs(hass, prefs={}): + """Fixture for cloud component.""" + prefs_to_set = { + cloud.STORAGE_ENABLE_ALEXA: True, + cloud.STORAGE_ENABLE_GOOGLE: True, + } + prefs_to_set.update(prefs) + hass.data[cloud.DOMAIN]._prefs = prefs_to_set + return prefs_to_set diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py new file mode 100644 index 00000000000..81ecb7250ef --- /dev/null +++ b/tests/components/cloud/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures for cloud tests.""" +import pytest + +from . import mock_cloud, mock_cloud_prefs + + +@pytest.fixture +def mock_cloud_fixture(hass): + """Fixture for cloud component.""" + mock_cloud(hass) + return mock_cloud_prefs(hass) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 531cd09f011..5d4b356b9b2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -5,11 +5,12 @@ from unittest.mock import patch, MagicMock import pytest from jose import jwt -from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, auth_api, iot +from homeassistant.components.cloud import ( + DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) from tests.common import mock_coro +from . import mock_cloud, mock_cloud_prefs GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync' SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info' @@ -25,22 +26,16 @@ def mock_auth(): @pytest.fixture(autouse=True) def setup_api(hass): """Initialize HTTP API.""" - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - assert hass.loop.run_until_complete(async_setup_component( - hass, 'cloud', { - 'cloud': { - 'mode': 'development', - 'cognito_client_id': 'cognito_client_id', - 'user_pool_id': 'user_pool_id', - 'region': 'region', - 'relayer': 'relayer', - 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, - 'subscription_info_url': SUBSCRIPTION_INFO_URL, - } - })) - hass.data['cloud']._decode_claims = \ - lambda token: jwt.get_unverified_claims(token) + mock_cloud(hass, { + 'mode': 'development', + 'cognito_client_id': 'cognito_client_id', + 'user_pool_id': 'user_pool_id', + 'region': 'region', + 'relayer': 'relayer', + 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, + 'subscription_info_url': SUBSCRIPTION_INFO_URL, + }) + return mock_cloud_prefs(hass) @pytest.fixture @@ -321,7 +316,7 @@ def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 -async def test_websocket_status(hass, hass_ws_client): +async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): """Test querying the status.""" hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', @@ -338,6 +333,8 @@ async def test_websocket_status(hass, hass_ws_client): 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', + 'alexa_enabled': True, + 'google_enabled': True, } @@ -407,3 +404,26 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): assert not response['success'] assert response['error']['code'] == 'not_logged_in' + + +async def test_websocket_update_preferences(hass, hass_ws_client, + aioclient_mock, setup_api): + """Test updating preference.""" + assert setup_api[STORAGE_ENABLE_GOOGLE] + assert setup_api[STORAGE_ENABLE_ALEXA] + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/update_prefs', + 'alexa_enabled': False, + 'google_enabled': False, + }) + response = await client.receive_json() + + assert response['success'] + assert not setup_api[STORAGE_ENABLE_GOOGLE] + assert not setup_api[STORAGE_ENABLE_ALEXA] diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 9b090a96ca1..1fdbda496a9 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -141,9 +141,9 @@ def test_write_user_info(): @asyncio.coroutine -def test_subscription_expired(): +def test_subscription_expired(hass): """Test subscription being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) token_val = { 'custom:sub-exp': '2017-11-13' } @@ -154,9 +154,9 @@ def test_subscription_expired(): @asyncio.coroutine -def test_subscription_not_expired(): +def test_subscription_not_expired(hass): """Test subscription not being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) token_val = { 'custom:sub-exp': '2017-11-13' } diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 1b580d0eb9b..07ec1851fbe 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -6,10 +6,14 @@ from aiohttp import WSMsgType, client_exceptions import pytest from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import Cloud, iot, auth_api, MODE_DEV +from homeassistant.components.cloud import ( + Cloud, iot, auth_api, MODE_DEV, STORAGE_ENABLE_ALEXA, + STORAGE_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro +from . import mock_cloud_prefs + @pytest.fixture def mock_client(): @@ -284,6 +288,8 @@ def test_handler_alexa(hass): }) assert setup + mock_cloud_prefs(hass) + resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) @@ -299,6 +305,20 @@ def test_handler_alexa(hass): assert device['manufacturerName'] == 'Home Assistant' +@asyncio.coroutine +def test_handler_alexa_disabled(hass, mock_cloud_fixture): + """Test handler Alexa when user has disabled it.""" + mock_cloud_fixture[STORAGE_ENABLE_ALEXA] = False + + resp = yield from iot.async_handle_alexa( + hass, hass.data['cloud'], + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + assert resp['event']['header']['namespace'] == 'Alexa' + assert resp['event']['header']['name'] == 'ErrorResponse' + assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' + + @asyncio.coroutine def test_handler_google_actions(hass): """Test handler Google Actions.""" @@ -327,6 +347,8 @@ def test_handler_google_actions(hass): }) assert setup + mock_cloud_prefs(hass) + reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} @@ -351,6 +373,24 @@ def test_handler_google_actions(hass): assert device['roomHint'] == 'living room' +async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): + """Test handler Google Actions when user has disabled it.""" + mock_cloud_fixture[STORAGE_ENABLE_GOOGLE] = False + + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): + assert await async_setup_component(hass, 'cloud', {}) + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + resp = await iot.async_handle_google_actions( + hass, hass.data['cloud'], data) + + assert resp['requestId'] == reqid + assert resp['payload']['errorCode'] == 'deviceTurnedOff' + + async def test_refresh_token_expired(hass): """Test handling Unauthenticated error raised if refresh token expired.""" cloud = Cloud(hass, MODE_DEV, None, None)