From ad391f22ff8e8740351d83c28a68dd92e86dd2e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 20:10:15 -0700 Subject: [PATCH 1/2] Ask users for a pin when interacting with locks/garage doors --- homeassistant/components/cloud/client.py | 2 +- homeassistant/components/cloud/const.py | 2 +- homeassistant/components/cloud/http_api.py | 21 +- homeassistant/components/cloud/prefs.py | 12 +- .../components/google_assistant/__init__.py | 8 +- .../components/google_assistant/const.py | 15 +- .../components/google_assistant/error.py | 29 +++ .../components/google_assistant/helpers.py | 13 +- .../components/google_assistant/http.py | 21 +- .../components/google_assistant/smart_home.py | 6 +- .../components/google_assistant/trait.py | 66 ++++-- tests/components/cloud/__init__.py | 2 +- tests/components/cloud/test_http_api.py | 9 +- .../google_assistant/test_smart_home.py | 3 - .../components/google_assistant/test_trait.py | 198 +++++++++++++----- 15 files changed, 279 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 9e24b619460f39..aedd71bd9ac1d7 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -102,7 +102,7 @@ def should_expose(entity): self._google_config = ga_h.Config( should_expose=should_expose, - allow_unlock=self._prefs.google_allow_unlock, + secure_devices_pin=self._prefs.google_secure_devices_pin, entity_config=google_conf.get(CONF_ENTITY_CONFIG), ) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 1286832c0c7abf..5002286edb9376 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -5,7 +5,7 @@ PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' PREF_ENABLE_REMOTE = 'remote_enabled' -PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin' PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUD_USER = 'cloud_user' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6ab7d911d472b2..bf9b78335274ef 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -19,7 +19,7 @@ from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks) + PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks) _LOGGER = logging.getLogger(__name__) @@ -30,15 +30,6 @@ }) -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(PREF_ENABLE_GOOGLE): bool, - vol.Optional(PREF_ENABLE_ALEXA): bool, - vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, -}) - - WS_TYPE_SUBSCRIPTION = 'cloud/subscription' SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SUBSCRIPTION, @@ -77,9 +68,7 @@ async def async_setup(hass): SCHEMA_WS_SUBSCRIPTION ) hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_PREFS, websocket_update_prefs, - SCHEMA_WS_UPDATE_PREFS - ) + websocket_update_prefs) hass.components.websocket_api.async_register_command( WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE @@ -358,6 +347,12 @@ async def websocket_subscription(hass, connection, msg): @_require_cloud_login @websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'cloud/update_prefs', + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), +}) async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index b0244f6b1fb162..0e2abae15b0b73 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -3,7 +3,7 @@ from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, - PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER, + PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, InvalidTrustedNetworks) STORAGE_KEY = DOMAIN @@ -29,7 +29,7 @@ async def async_initialize(self): PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, - PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } @@ -38,14 +38,14 @@ async def async_initialize(self): async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, - google_allow_unlock=_UNDEF, cloudhooks=_UNDEF, + google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, cloud_user=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), (PREF_ENABLE_REMOTE, remote_enabled), - (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), ): @@ -85,9 +85,9 @@ def google_enabled(self): return self._prefs[PREF_ENABLE_GOOGLE] @property - def google_allow_unlock(self): + def google_secure_devices_pin(self): """Return if Google is allowed to unlock locks.""" - return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) @property def cloudhooks(self): diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 2d3a19afa1302a..19f9a0b1f99448 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -20,7 +20,7 @@ CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK, - DEFAULT_ALLOW_UNLOCK + CONF_SECURE_DEVICES_PIN ) from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401 from .const import EVENT_QUERY_RECEIVED # noqa: F401 @@ -43,8 +43,10 @@ default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_API_KEY): cv.string, vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, - vol.Optional(CONF_ALLOW_UNLOCK, - default=DEFAULT_ALLOW_UNLOCK): cv.boolean, + # No longer used since 0.92 + vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, + # str on purpose, makes sure it is configured correctly. + vol.Optional(CONF_SECURE_DEVICES_PIN): str, }, extra=vol.PREVENT_EXTRA) CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 67c767c080bb2f..07506611109e59 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -28,13 +28,13 @@ CONF_API_KEY = 'api_key' CONF_ROOM_HINT = 'room' CONF_ALLOW_UNLOCK = 'allow_unlock' +CONF_SECURE_DEVICES_PIN = 'secure_devices_pin' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', ] -DEFAULT_ALLOW_UNLOCK = False PREFIX_TYPES = 'action.devices.types.' TYPE_CAMERA = PREFIX_TYPES + 'CAMERA' @@ -55,7 +55,7 @@ REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' # Error codes used for SmartHomeError class -# https://developers.google.com/actions/smarthome/create-app#error_responses +# https://developers.google.com/actions/reference/smarthome/errors-exceptions ERR_DEVICE_OFFLINE = "deviceOffline" ERR_DEVICE_NOT_FOUND = "deviceNotFound" ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" @@ -64,6 +64,12 @@ ERR_UNKNOWN_ERROR = 'unknownError' ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' +ERR_CHALLENGE_NEEDED = 'challengeNeeded' +ERR_CHALLENGE_NOT_SETUP = 'challengeFailedNotSetup' +ERR_TOO_MANY_FAILED_ATTEMPTS = 'tooManyFailedAttempts' +ERR_PIN_INCORRECT = 'pinIncorrect' +ERR_USER_CANCELLED = 'userCancelled' + # Event types EVENT_COMMAND_RECEIVED = 'google_assistant_command' EVENT_QUERY_RECEIVED = 'google_assistant_query' @@ -95,5 +101,8 @@ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, - } + +CHALLENGE_ACK_NEEDED = 'ackNeeded' +CHALLENGE_PIN_NEEDED = 'pinNeeded' +CHALLENGE_FAILED_PIN_NEEDED = 'challengeFailedPinNeeded' diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py index 2225bb58242658..3aef1e9408d5e9 100644 --- a/homeassistant/components/google_assistant/error.py +++ b/homeassistant/components/google_assistant/error.py @@ -1,4 +1,5 @@ """Errors for Google Assistant.""" +from .const import ERR_CHALLENGE_NEEDED class SmartHomeError(Exception): @@ -11,3 +12,31 @@ def __init__(self, code, msg): """Log error code.""" super().__init__(msg) self.code = code + + def to_response(self): + """Convert to a response format.""" + return { + 'errorCode': self.code + } + + +class ChallengeNeeded(SmartHomeError): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, challenge_type): + """Initialize challenge needed error.""" + super().__init__(ERR_CHALLENGE_NEEDED, + 'Challenge needed: {}'.format(challenge_type)) + self.challenge_type = challenge_type + + def to_response(self): + """Convert to a response format.""" + return { + 'errorCode': self.code, + 'challengeNeeded': { + 'type': self.challenge_type + } + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 982b840393e151..71cce9de500752 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -19,12 +19,12 @@ class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, allow_unlock, - entity_config=None): + def __init__(self, should_expose, + entity_config=None, secure_devices_pin=None): """Initialize the configuration.""" self.should_expose = should_expose self.entity_config = entity_config or {} - self.allow_unlock = allow_unlock + self.secure_devices_pin = secure_devices_pin class RequestData: @@ -168,15 +168,18 @@ def query_serialize(self): return attrs - async def execute(self, command, data, params): + async def execute(self, data, command_payload): """Execute a command. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute """ + command = command_payload['command'] + params = command_payload.get('params', {}) + challenge = command_payload.get('challenge', {}) executed = False for trt in self.traits(): if trt.can_execute(command, params): - await trt.execute(command, data, params) + await trt.execute(command, data, params, challenge) executed = True break diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 11d8a3841650d7..d385d742c7d180 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,12 +10,12 @@ from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_ALLOW_UNLOCK, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, CONF_EXPOSE, - ) + CONF_SECURE_DEVICES_PIN, +) from .smart_home import async_handle_message from .helpers import Config @@ -28,7 +28,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} - allow_unlock = cfg.get(CONF_ALLOW_UNLOCK, False) + secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -53,8 +53,13 @@ def is_exposed(entity) -> bool: return is_default_exposed or explicit_expose - hass.http.register_view( - GoogleAssistantView(is_exposed, entity_config, allow_unlock)) + config = Config( + should_expose=is_exposed, + entity_config=entity_config, + secure_devices_pin=secure_devices_pin + ) + + hass.http.register_view(GoogleAssistantView(config)) class GoogleAssistantView(HomeAssistantView): @@ -64,11 +69,9 @@ class GoogleAssistantView(HomeAssistantView): name = 'api:google_assistant' requires_auth = True - def __init__(self, is_exposed, entity_config, allow_unlock): + def __init__(self, config): """Initialize the Google Assistant request handler.""" - self.config = Config(is_exposed, - allow_unlock, - entity_config) + self.config = config async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 9edde36f09d716..37f35edf64528f 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -177,14 +177,12 @@ async def handle_devices_execute(hass, data, payload): entities[entity_id] = GoogleEntity(hass, data.config, state) try: - await entities[entity_id].execute(execution['command'], - data, - execution.get('params', {})) + await entities[entity_id].execute(data, execution) except SmartHomeError as err: results[entity_id] = { 'ids': [entity_id], 'status': 'ERROR', - 'errorCode': err.code + **err.to_response() } final_results = list(results.values()) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 5bec683ccc744e..bad186a4edb087 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import const as climate from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_DEVICE_CLASS, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_LOCKED, @@ -37,8 +38,12 @@ ERR_VALUE_OUT_OF_RANGE, ERR_NOT_SUPPORTED, ERR_FUNCTION_NOT_SUPPORTED, + ERR_CHALLENGE_NOT_SETUP, + CHALLENGE_ACK_NEEDED, + CHALLENGE_PIN_NEEDED, + CHALLENGE_FAILED_PIN_NEEDED, ) -from .error import SmartHomeError +from .error import SmartHomeError, ChallengeNeeded _LOGGER = logging.getLogger(__name__) @@ -114,7 +119,7 @@ def can_execute(self, command, params): """Test if command can be executed.""" return command in self.commands - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a trait command.""" raise NotImplementedError @@ -164,7 +169,7 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a brightness command.""" domain = self.state.domain @@ -219,7 +224,7 @@ def query_attributes(self): """Return camera stream attributes.""" return self.stream_info or {} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a get camera stream command.""" url = await self.hass.components.camera.async_request_stream( self.state.entity_id, 'hls') @@ -260,7 +265,7 @@ def query_attributes(self): """Return OnOff query attributes.""" return {'on': self.state.state != STATE_OFF} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an OnOff command.""" domain = self.state.domain @@ -353,7 +358,7 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a color temperature command.""" if 'temperature' in params['color']: temp = color_util.color_temperature_kelvin_to_mired( @@ -424,7 +429,7 @@ def query_attributes(self): """Return scene query attributes.""" return {} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( @@ -459,7 +464,7 @@ def query_attributes(self): """Return dock query attributes.""" return {'isDocked': self.state.state == vacuum.STATE_DOCKED} - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a dock command.""" await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, { @@ -498,7 +503,7 @@ def query_attributes(self): 'isPaused': self.state.state == vacuum.STATE_PAUSED, } - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" if command == COMMAND_STARTSTOP: if params['start']: @@ -634,7 +639,7 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute a temperature point or mode command.""" # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit @@ -748,13 +753,10 @@ def query_attributes(self): """Return LockUnlock query attributes.""" return {'isLocked': self.state.state == STATE_LOCKED} - def can_execute(self, command, params): - """Test if command can be executed.""" - allowed_unlock = not params['lock'] and self.config.allow_unlock - return params['lock'] or allowed_unlock - - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" + _verify_pin_challenge(data, challenge) + if params['lock']: service = lock.SERVICE_LOCK else: @@ -832,7 +834,7 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an SetFanSpeed command.""" await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, { @@ -1006,7 +1008,7 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an SetModes command.""" settings = params.get('updateModeSettings') requested_source = settings.get( @@ -1097,11 +1099,16 @@ def query_attributes(self): return response - async def execute(self, command, data, params): + async def execute(self, command, data, params, challenge): """Execute an Open, close, Set position command.""" domain = self.state.domain if domain == cover.DOMAIN: + if self.state.attributes.get(ATTR_DEVICE_CLASS) in ( + cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE + ): + _verify_pin_challenge(data, challenge) + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) if params['openPercent'] == 0: await self.hass.services.async_call( @@ -1123,3 +1130,24 @@ async def execute(self, command, data, params): raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, 'Setting a position is not supported') + + +def _verify_pin_challenge(data, challenge): + """Verify a pin challenge.""" + if not data.config.secure_devices_pin: + raise SmartHomeError( + ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up') + + if not challenge: + raise ChallengeNeeded(CHALLENGE_PIN_NEEDED) + + pin = challenge.get('pin') + + if pin != data.config.secure_devices_pin: + raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) + + +def _verify_ack_challenge(data, challenge): + """Verify a pin challenge.""" + if not challenge or not challenge.get('ack'): + raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 3a07e52724f2a8..08ab5324b970e1 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -26,7 +26,7 @@ def mock_cloud_prefs(hass, prefs={}): prefs_to_set = { const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, - const.PREF_GOOGLE_ALLOW_UNLOCK: True, + const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c147f8492d7439..4aebc5679a0fd8 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -9,7 +9,8 @@ from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( - PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, + DOMAIN) from tests.common import mock_coro @@ -493,21 +494,21 @@ async def test_websocket_update_preferences(hass, hass_ws_client, """Test updating preference.""" assert setup_api[PREF_ENABLE_GOOGLE] assert setup_api[PREF_ENABLE_ALEXA] - assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] is None client = await hass_ws_client(hass) await client.send_json({ 'id': 5, 'type': 'cloud/update_prefs', 'alexa_enabled': False, 'google_enabled': False, - 'google_allow_unlock': False, + 'google_secure_devices_pin': '1234', }) response = await client.receive_json() assert response['success'] assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api[PREF_ENABLE_ALEXA] - assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] == '1234' async def test_enabling_webhook(hass, hass_ws_client, setup_api, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 30a398fccc38a4..8ea6f26553de7a 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -22,7 +22,6 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -57,7 +56,6 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', - allow_unlock=False, entity_config={ 'light.demo_light': { const.CONF_ROOM_HINT: 'Living Room', @@ -146,7 +144,6 @@ async def test_sync_in_area(hass, registries): config = helpers.Config( should_expose=lambda _: True, - allow_unlock=False, entity_config={} ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 12731978f57785..8b7f0788f34411 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -19,7 +19,8 @@ group, ) from homeassistant.components.climate import const as climate -from homeassistant.components.google_assistant import trait, helpers, const +from homeassistant.components.google_assistant import ( + trait, helpers, const, error) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, @@ -30,7 +31,6 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -41,9 +41,15 @@ REQ_ID, ) -UNSAFE_CONFIG = helpers.Config( +PIN_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=True, + secure_devices_pin='1234' +) + +PIN_DATA = helpers.RequestData( + PIN_CONFIG, + 'test-agent', + REQ_ID, ) @@ -69,7 +75,7 @@ async def test_brightness_light(hass): calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) await trt.execute( trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, - {'brightness': 50}) + {'brightness': 50}, {}) await hass.async_block_till_done() assert len(calls) == 1 @@ -108,7 +114,7 @@ async def test_brightness_media_player(hass): hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) await trt.execute( trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, - {'brightness': 60}) + {'brightness': 60}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -139,7 +145,7 @@ async def test_camera_stream(hass): with patch('homeassistant.components.camera.async_request_stream', return_value=mock_coro('/api/streams/bla')): - await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}, {}) assert trt.query_attributes() == { 'cameraStreamAccessUrl': 'http://1.1.1.1:8123/api/streams/bla' @@ -169,7 +175,7 @@ async def test_onoff_group(hass): on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -178,7 +184,7 @@ async def test_onoff_group(hass): off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -209,7 +215,7 @@ async def test_onoff_input_boolean(hass): on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -219,7 +225,7 @@ async def test_onoff_input_boolean(hass): SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -250,7 +256,7 @@ async def test_onoff_switch(hass): on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -259,7 +265,7 @@ async def test_onoff_switch(hass): off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -287,7 +293,7 @@ async def test_onoff_fan(hass): on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -296,7 +302,7 @@ async def test_onoff_fan(hass): off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -326,7 +332,7 @@ async def test_onoff_light(hass): on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -335,7 +341,7 @@ async def test_onoff_light(hass): off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -366,7 +372,7 @@ async def test_onoff_media_player(hass): on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': True}) + {'on': True}, {}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -377,7 +383,7 @@ async def test_onoff_media_player(hass): await trt_on.execute( trait.COMMAND_ONOFF, BASIC_DATA, - {'on': False}) + {'on': False}, {}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -408,7 +414,7 @@ async def test_dock_vacuum(hass): calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) await trt.execute( - trait.COMMAND_DOCK, BASIC_DATA, {}) + trait.COMMAND_DOCK, BASIC_DATA, {}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -433,7 +439,7 @@ async def test_startstop_vacuum(hass): start_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}, {}) assert len(start_calls) == 1 assert start_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -441,7 +447,8 @@ async def test_startstop_vacuum(hass): stop_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_STOP) - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}) + await trt.execute( + trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}, {}) assert len(stop_calls) == 1 assert stop_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -449,7 +456,8 @@ async def test_startstop_vacuum(hass): pause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_PAUSE) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}) + await trt.execute( + trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}, {}) assert len(pause_calls) == 1 assert pause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -457,7 +465,8 @@ async def test_startstop_vacuum(hass): unpause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}) + await trt.execute( + trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}, {}) assert len(unpause_calls) == 1 assert unpause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -502,7 +511,7 @@ async def test_color_setting_color_light(hass): 'color': { 'spectrumRGB': 1052927 } - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -517,7 +526,7 @@ async def test_color_setting_color_light(hass): 'value': .20, } } - }) + }, {}) assert len(calls) == 2 assert calls[1].data == { ATTR_ENTITY_ID: 'light.bla', @@ -565,14 +574,14 @@ async def test_color_setting_temperature_light(hass): 'color': { 'temperature': 5555 } - }) + }, {}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 2857 } - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -608,7 +617,7 @@ async def test_scene_scene(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'scene.bla', @@ -626,7 +635,7 @@ async def test_scene_script(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) # We don't wait till script execution is done. await hass.async_block_till_done() @@ -671,14 +680,14 @@ async def test_temperature_setting_climate_onoff(hass): hass, climate.DOMAIN, SERVICE_TURN_ON) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'on', - }) + }, {}) assert len(calls) == 1 calls = async_mock_service( hass, climate.DOMAIN, SERVICE_TURN_OFF) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'off', - }) + }, {}) assert len(calls) == 1 @@ -731,7 +740,7 @@ async def test_temperature_setting_climate_range(hass): trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, { 'thermostatTemperatureSetpointHigh': 25, 'thermostatTemperatureSetpointLow': 20, - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -743,7 +752,7 @@ async def test_temperature_setting_climate_range(hass): hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'heatcool', - }) + }, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -753,7 +762,7 @@ async def test_temperature_setting_climate_range(hass): with pytest.raises(helpers.SmartHomeError) as err: await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': -100}) + {'thermostatTemperatureSetpoint': -100}, {}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE hass.config.units.temperature_unit = TEMP_CELSIUS @@ -799,11 +808,11 @@ async def test_temperature_setting_climate_setpoint(hass): with pytest.raises(helpers.SmartHomeError): await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': -100}) + {'thermostatTemperatureSetpoint': -100}, {}) await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': 19}) + {'thermostatTemperatureSetpoint': 19}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -851,7 +860,7 @@ async def test_temperature_setting_climate_setpoint_auto(hass): await trt.execute( trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, - {'thermostatTemperatureSetpoint': 19}) + {'thermostatTemperatureSetpoint': 19}, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -867,7 +876,7 @@ async def test_lock_unlock_lock(hass): trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_UNLOCKED), - BASIC_CONFIG) + PIN_CONFIG) assert trt.sync_attributes() == {} @@ -878,7 +887,26 @@ async def test_lock_unlock_lock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': True}) + + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED + + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, + {'pin': 9999}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED + + await trt.execute(trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, + {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { @@ -894,7 +922,7 @@ async def test_lock_unlock_unlock(hass): trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_LOCKED), - BASIC_CONFIG) + PIN_CONFIG) assert trt.sync_attributes() == {} @@ -902,22 +930,29 @@ async def test_lock_unlock_unlock(hass): 'isLocked': True } - assert not trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) - - trt = trait.LockUnlockTrait(hass, - State('lock.front_door', lock.STATE_LOCKED), - UNSAFE_CONFIG) + assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) - assert trt.sync_attributes() == {} + calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - assert trt.query_attributes() == { - 'isLocked': True - } + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED - assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, + {'pin': 9999}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED - calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}) + await trt.execute( + trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { @@ -1000,7 +1035,7 @@ async def test_fan_speed(hass): calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) await trt.execute( - trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}) + trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}, {}) assert len(calls) == 1 assert calls[0].data == { @@ -1089,7 +1124,7 @@ async def test_modes(hass): trait.COMMAND_MODES, BASIC_DATA, { 'updateModeSettings': { trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' - }}) + }}, {}) assert len(calls) == 1 assert calls[0].data == { @@ -1145,7 +1180,58 @@ async def test_openclose_cover(hass): hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) await trt.execute( trait.COMMAND_OPENCLOSE, BASIC_DATA, - {'openPercent': 50}) + {'openPercent': 50}, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + cover.ATTR_POSITION: 50 + } + + +@pytest.mark.parametrize('device_class', ( + cover.DEVICE_CLASS_DOOR, + cover.DEVICE_CLASS_GARAGE, +)) +async def test_openclose_cover_secure(hass, device_class): + """Test OpenClose trait support for cover domain.""" + assert helpers.get_google_type(cover.DOMAIN, device_class) is not None + assert trait.OpenCloseTrait.supported( + cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) + + trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, { + ATTR_DEVICE_CLASS: device_class, + cover.ATTR_CURRENT_POSITION: 75 + }), PIN_CONFIG) + + assert trt.sync_attributes() == {} + assert trt.query_attributes() == { + 'openPercent': 75 + } + + calls = async_mock_service( + hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + + # No challenge data + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_PIN_NEEDED + + # invalid pin + with pytest.raises(error.ChallengeNeeded) as err: + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {'pin': '9999'}) + assert len(calls) == 0 + assert err.code == const.ERR_CHALLENGE_NEEDED + assert err.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED + + await trt.execute( + trait.COMMAND_OPENCLOSE, PIN_DATA, + {'openPercent': 50}, {'pin': '1234'}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', From ac420767a1019f7cf15daddc26c1ead3dcdb3770 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 18 Apr 2019 20:23:24 -0700 Subject: [PATCH 2/2] Deprecate allow_unlock option --- .../components/google_assistant/__init__.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 19f9a0b1f99448..c8078b7d9d2285 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -35,19 +35,20 @@ vol.Optional(CONF_ROOM_HINT): cv.string, }) -GOOGLE_ASSISTANT_SCHEMA = vol.Schema({ - vol.Required(CONF_PROJECT_ID): cv.string, - vol.Optional(CONF_EXPOSE_BY_DEFAULT, - default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, - vol.Optional(CONF_EXPOSED_DOMAINS, - default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, - # No longer used since 0.92 - vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, - # str on purpose, makes sure it is configured correctly. - vol.Optional(CONF_SECURE_DEVICES_PIN): str, -}, extra=vol.PREVENT_EXTRA) +GOOGLE_ASSISTANT_SCHEMA = vol.All( + cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version='0.95'), + vol.Schema({ + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Optional(CONF_EXPOSE_BY_DEFAULT, + default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS, + default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, + vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, + # str on purpose, makes sure it is configured correctly. + vol.Optional(CONF_SECURE_DEVICES_PIN): str, + }, extra=vol.PREVENT_EXTRA)) CONFIG_SCHEMA = vol.Schema({ DOMAIN: GOOGLE_ASSISTANT_SCHEMA