diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 38ae09ced93..c7626777943 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -110,14 +110,17 @@ class CloudClient(Interface): if not self.cloud.is_logged_in: return - if self.alexa_config.should_report_state: + if self.alexa_config.enabled and self.alexa_config.should_report_state: try: await self.alexa_config.async_enable_proactive_mode() except alexa_errors.NoTokenAvailable: pass - if self.google_config.should_report_state: - self.google_config.async_enable_report_state() + if self.google_config.enabled: + self.google_config.async_enable_local_sdk() + + if self.google_config.should_report_state: + self.google_config.async_enable_report_state() async def cleanups(self) -> None: """Cleanup some stuff after logout.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index e28d75f017d..6495cba23b7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -16,6 +16,7 @@ PREF_OVERRIDE_NAME = "override_name" PREF_DISABLE_2FA = "disable_2fa" PREF_ALIASES = "aliases" PREF_SHOULD_EXPOSE = "should_expose" +PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" DEFAULT_SHOULD_EXPOSE = True DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = False diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 38e4aec56e0..582fa007550 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -63,6 +63,19 @@ class CloudGoogleConfig(AbstractConfig): """Return if states should be proactively reported.""" return self._prefs.google_report_state + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook. + + Return None to disable the local SDK. + """ + return self._prefs.google_local_webhook_id + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + return self._prefs.cloud_user + def should_expose(self, state): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) @@ -131,17 +144,19 @@ class CloudGoogleConfig(AbstractConfig): # State reporting is reported as a property on entities. # So when we change it, we need to sync all entities. await self.async_sync_entities() - return # If entity prefs are the same or we have filter in config.yaml, # don't sync. - if ( - self._cur_entity_prefs is prefs.google_entity_configs - or not self._config["filter"].empty_filter + elif ( + self._cur_entity_prefs is not prefs.google_entity_configs + and self._config["filter"].empty_filter ): - return + self.async_schedule_google_sync() - self.async_schedule_google_sync() + if self.enabled and not self.is_local_sdk_active: + self.async_enable_local_sdk() + elif not self.enabled and self.is_local_sdk_active: + self.async_disable_local_sdk() async def _handle_entity_registry_updated(self, event): """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index a8ff775a227..0599b00a8bd 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -21,6 +21,7 @@ from .const import ( PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE, PREF_GOOGLE_REPORT_STATE, + PREF_GOOGLE_LOCAL_WEBHOOK_ID, DEFAULT_GOOGLE_REPORT_STATE, InvalidTrustedNetworks, InvalidTrustedProxies, @@ -59,6 +60,14 @@ class CloudPreferences: self._prefs = prefs + if PREF_GOOGLE_LOCAL_WEBHOOK_ID not in self._prefs: + await self._save_prefs( + { + **self._prefs, + PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + } + ) + @callback def async_listen_updates(self, listener): """Listen for updates to the preferences.""" @@ -79,6 +88,8 @@ class CloudPreferences: google_report_state=_UNDEF, ): """Update user preferences.""" + prefs = {**self._prefs} + for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), @@ -92,20 +103,17 @@ class CloudPreferences: (PREF_GOOGLE_REPORT_STATE, google_report_state), ): if value is not _UNDEF: - self._prefs[key] = value + prefs[key] = value if remote_enabled is True and self._has_local_trusted_network: - self._prefs[PREF_ENABLE_REMOTE] = False + prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedNetworks if remote_enabled is True and self._has_local_trusted_proxies: - self._prefs[PREF_ENABLE_REMOTE] = False + prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedProxies - await self._store.async_save(self._prefs) - - for listener in self._listeners: - self._hass.async_create_task(async_create_catching_coro(listener(self))) + await self._save_prefs(prefs) async def async_update_google_entity_config( self, @@ -216,6 +224,11 @@ class CloudPreferences: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_local_webhook_id(self): + """Return Google webhook ID to receive local messages.""" + return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + @property def alexa_entity_configs(self): """Return Alexa Entity configurations.""" @@ -262,3 +275,11 @@ class CloudPreferences: return True return False + + async def _save_prefs(self, prefs): + """Save preferences to disk.""" + self._prefs = prefs + await self._store.async_save(self._prefs) + + for listener in self._listeners: + self._hass.async_create_task(async_create_catching_coro(listener(self))) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 933f0c07999..96b9b93d70a 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,10 +1,15 @@ """Helper classes for Google Assistant integration.""" from asyncio import gather from collections.abc import Mapping -from typing import List +import logging +import pprint +from typing import List, Optional + +from aiohttp.web import json_response from homeassistant.core import Context, callback, HomeAssistant, State from homeassistant.helpers.event import async_call_later +from homeassistant.components import webhook from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, @@ -15,6 +20,7 @@ from homeassistant.const import ( from . import trait from .const import ( + DOMAIN, DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, @@ -24,6 +30,7 @@ from .const import ( from .error import SmartHomeError SYNC_DELAY = 15 +_LOGGER = logging.getLogger(__name__) class AbstractConfig: @@ -35,6 +42,7 @@ class AbstractConfig: """Initialize abstract config.""" self.hass = hass self._google_sync_unsub = None + self._local_sdk_active = False @property def enabled(self): @@ -61,12 +69,30 @@ class AbstractConfig: """Return if we're actively reporting states.""" return self._unsub_report_state is not None + @property + def is_local_sdk_active(self): + """Return if we're actively accepting local messages.""" + return self._local_sdk_active + @property def should_report_state(self): """Return if states should be proactively reported.""" # pylint: disable=no-self-use return False + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook ID. + + Return None to disable the local SDK. + """ + return None + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + raise NotImplementedError + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" raise NotImplementedError @@ -131,15 +157,66 @@ class AbstractConfig: Called when the user disconnects their account from Google. """ + @callback + def async_enable_local_sdk(self): + """Enable the local SDK.""" + webhook_id = self.local_sdk_webhook_id + + if webhook_id is None: + return + + webhook.async_register( + self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook + ) + + self._local_sdk_active = True + + @callback + def async_disable_local_sdk(self): + """Disable the local SDK.""" + if not self._local_sdk_active: + return + + webhook.async_unregister(self.hass, self.local_sdk_webhook_id) + self._local_sdk_active = False + + async def _handle_local_webhook(self, hass, webhook_id, request): + """Handle an incoming local SDK message.""" + from . import smart_home + + payload = await request.json() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received local message:\n%s\n", pprint.pformat(payload)) + + if not self.enabled: + return json_response(smart_home.turned_off_response(payload)) + + result = await smart_home.async_handle_message( + self.hass, self, self.local_sdk_user_id, payload + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Responding to local message:\n%s\n", pprint.pformat(result)) + + return json_response(result) + class RequestData: """Hold data associated with a particular request.""" - def __init__(self, config: AbstractConfig, user_id: str, request_id: str): + def __init__( + self, + config: AbstractConfig, + user_id: str, + request_id: str, + devices: Optional[List[dict]], + ): """Initialize the request data.""" self.config = config self.request_id = request_id self.context = Context(user_id=user_id) + self.devices = devices def get_google_type(domain, device_class): @@ -234,6 +311,15 @@ class GoogleEntity: if aliases: device["name"]["nicknames"] = aliases + if self.config.is_local_sdk_active: + device["otherDeviceIds"] = [{"deviceId": self.entity_id}] + device["customData"] = { + "webhookId": self.config.local_sdk_webhook_id, + "httpPort": self.hass.config.api.port, + "httpSSL": self.hass.config.api.use_ssl, + "proxyDeviceId": self.config.agent_user_id, + } + for trt in traits: device["attributes"].update(trt.sync_attributes()) @@ -280,6 +366,11 @@ class GoogleEntity: return attrs + @callback + def reachable_device_serialize(self): + """Serialize entity for a REACHABLE_DEVICE response.""" + return {"verificationId": self.entity_id} + async def execute(self, data, command_payload): """Execute a command. diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f9b311a3880..0944c9532ef 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -5,7 +5,7 @@ import logging from homeassistant.util.decorator import Registry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, __version__ from .const import ( ERR_PROTOCOL_ERROR, @@ -24,9 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - request_id: str = message.get("requestId") - - data = RequestData(config, user_id, request_id) + data = RequestData(config, user_id, message["requestId"], message.get("devices")) response = await _process(hass, data, message) @@ -67,6 +65,7 @@ async def _process(hass, data, message): if result is None: return None + return {"requestId": data.request_id, "payload": result} @@ -74,7 +73,7 @@ async def _process(hass, data, message): async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. - https://developers.google.com/actions/smarthome/create-app#actiondevicessync + https://developers.google.com/assistant/smarthome/develop/process-intents#SYNC """ hass.bus.async_fire( EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context @@ -84,7 +83,7 @@ async def async_devices_sync(hass, data, payload): *( entity.sync_serialize() for entity in async_get_entities(hass, data.config) - if data.config.should_expose(entity.state) + if entity.should_expose() ) ) @@ -100,7 +99,7 @@ async def async_devices_sync(hass, data, payload): async def async_devices_query(hass, data, payload): """Handle action.devices.QUERY request. - https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + https://developers.google.com/assistant/smarthome/develop/process-intents#QUERY """ devices = {} for device in payload.get("devices", []): @@ -128,7 +127,7 @@ async def async_devices_query(hass, data, payload): async def handle_devices_execute(hass, data, payload): """Handle action.devices.EXECUTE request. - https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + https://developers.google.com/assistant/smarthome/develop/process-intents#EXECUTE """ entities = {} results = {} @@ -196,12 +195,50 @@ async def handle_devices_execute(hass, data, payload): async def async_devices_disconnect(hass, data: RequestData, payload): """Handle action.devices.DISCONNECT request. - https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect + https://developers.google.com/assistant/smarthome/develop/process-intents#DISCONNECT """ await data.config.async_deactivate_report_state() return None +@HANDLERS.register("action.devices.IDENTIFY") +async def async_devices_identify(hass, data: RequestData, payload): + """Handle action.devices.IDENTIFY request. + + https://developers.google.com/assistant/smarthome/develop/local#implement_the_identify_handler + """ + return { + "device": { + "id": data.config.agent_user_id, + "isLocalOnly": True, + "isProxy": True, + "deviceInfo": { + "hwVersion": "UNKNOWN_HW_VERSION", + "manufacturer": "Home Assistant", + "model": "Home Assistant", + "swVersion": __version__, + }, + } + } + + +@HANDLERS.register("action.devices.REACHABLE_DEVICES") +async def async_devices_reachable(hass, data: RequestData, payload): + """Handle action.devices.REACHABLE_DEVICES request. + + https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect + """ + google_ids = set(dev["id"] for dev in (data.devices or [])) + + return { + "devices": [ + entity.reachable_device_serialize() + for entity in async_get_entities(hass, data.config) + if entity.entity_id in google_ids and entity.should_expose() + ] + } + + def turned_off_response(message): """Return a device turned off response.""" return { diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a8aaa3390a7..1b61e74769f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -128,6 +128,7 @@ class ApiConfig: """Initialize a new API config object.""" self.host = host self.port = port + self.use_ssl = use_ssl host = host.rstrip("/") if host.startswith(("http://", "https://")): diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index a12e55c771a..5a41bfa9851 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,7 +1,7 @@ """Webhooks for Home Assistant.""" import logging -from aiohttp.web import Response +from aiohttp.web import Response, Request import voluptuous as vol from homeassistant.core import callback @@ -98,9 +98,11 @@ class WebhookView(HomeAssistantView): url = URL_WEBHOOK_PATH name = "api:webhook" requires_auth = False + cors_allowed = True - async def _handle(self, request, webhook_id): + async def _handle(self, request: Request, webhook_id): """Handle webhook call.""" + _LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id) hass = request.app["hass"] return await async_handle_webhook(hass, webhook_id, request) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index af107a6ae0d..2f9fb7b4580 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,7 +11,11 @@ import voluptuous as vol from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from homeassistant import util -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START, + __version__, +) from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT _LOGGER = logging.getLogger(__name__) @@ -33,6 +37,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Set up Zeroconf and make Home Assistant discoverable.""" + zeroconf = Zeroconf() zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}" params = { @@ -58,9 +63,15 @@ def setup(hass, config): properties=params, ) - zeroconf = Zeroconf() + def zeroconf_hass_start(_event): + """Expose Home Assistant on zeroconf when it starts. - zeroconf.register_service(info) + Wait till started or otherwise HTTP is not up and running. + """ + _LOGGER.info("Starting Zeroconf broadcast") + zeroconf.register_service(info) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start) def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" diff --git a/tests/common.py b/tests/common.py index 0684e6daafc..40e02842146 100644 --- a/tests/common.py +++ b/tests/common.py @@ -230,7 +230,6 @@ def get_test_instance_port(): return _TEST_INSTANCE_PORT -@ha.callback def async_mock_service(hass, domain, service, schema=None): """Set up a fake service & return a calls log list to this service.""" calls = [] diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index a0196cae32a..45ea4e43ee4 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -25,4 +25,4 @@ def mock_cloud_prefs(hass, prefs={}): } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set - return prefs_to_set + return hass.data[cloud.DOMAIN].client._prefs diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index b7ac5f4cffd..054b38daffc 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -61,7 +61,7 @@ async def test_handler_alexa(hass): async def test_handler_alexa_disabled(hass, mock_cloud_fixture): """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_ALEXA] = False + mock_cloud_fixture._prefs[PREF_ENABLE_ALEXA] = False cloud = hass.data["cloud"] resp = await cloud.client.async_alexa_message( @@ -125,7 +125,7 @@ async def test_handler_google_actions(hass): async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False + mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()): assert await async_setup_component(hass, "cloud", {}) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 8e03fb82b2c..314db3a9e88 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -10,13 +10,7 @@ from hass_nabucasa.const import STATE_CONNECTED from homeassistant.core import State from homeassistant.auth.providers import trusted_networks as tn_auth -from homeassistant.components.cloud.const import ( - PREF_ENABLE_GOOGLE, - PREF_ENABLE_ALEXA, - PREF_GOOGLE_SECURE_DEVICES_PIN, - DOMAIN, - RequireRelink, -) +from homeassistant.components.cloud.const import DOMAIN, RequireRelink from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.alexa import errors as alexa_errors @@ -474,9 +468,9 @@ async def test_websocket_update_preferences( hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login ): """Test updating preference.""" - assert setup_api[PREF_ENABLE_GOOGLE] - assert setup_api[PREF_ENABLE_ALEXA] - assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] is None + assert setup_api.google_enabled + assert setup_api.alexa_enabled + assert setup_api.google_secure_devices_pin is None client = await hass_ws_client(hass) await client.send_json( { @@ -490,9 +484,9 @@ async def test_websocket_update_preferences( response = await client.receive_json() assert response["success"] - assert not setup_api[PREF_ENABLE_GOOGLE] - assert not setup_api[PREF_ENABLE_ALEXA] - assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] == "1234" + assert not setup_api.google_enabled + assert not setup_api.alexa_enabled + assert setup_api.google_secure_devices_pin == "1234" async def test_websocket_update_preferences_require_relink( diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 8049ac4b0db..09522e9c86f 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -12,12 +12,23 @@ class MockConfig(helpers.AbstractConfig): should_expose=None, entity_config=None, hass=None, + local_sdk_webhook_id=None, + local_sdk_user_id=None, + enabled=True, ): """Initialize config.""" super().__init__(hass) self._should_expose = should_expose self._secure_devices_pin = secure_devices_pin self._entity_config = entity_config or {} + self._local_sdk_webhook_id = local_sdk_webhook_id + self._local_sdk_user_id = local_sdk_user_id + self._enabled = enabled + + @property + def enabled(self): + """Return if Google is enabled.""" + return self._enabled @property def secure_devices_pin(self): @@ -29,6 +40,16 @@ class MockConfig(helpers.AbstractConfig): """Return secure devices pin.""" return self._entity_config + @property + def local_sdk_webhook_id(self): + """Return local SDK webhook id.""" + return self._local_sdk_webhook_id + + @property + def local_sdk_user_id(self): + """Return local SDK webhook id.""" + return self._local_sdk_user_id + def should_expose(self, state): """Expose it all.""" return self._should_expose is None or self._should_expose(state) diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py new file mode 100644 index 00000000000..497b7b1f0ae --- /dev/null +++ b/tests/components/google_assistant/test_helpers.py @@ -0,0 +1,130 @@ +"""Test Google Assistant helpers.""" +from unittest.mock import Mock +from homeassistant.setup import async_setup_component +from homeassistant.components.google_assistant import helpers +from homeassistant.components.google_assistant.const import EVENT_COMMAND_RECEIVED +from . import MockConfig + +from tests.common import async_capture_events, async_mock_service + + +async def test_google_entity_sync_serialize_with_local_sdk(hass): + """Test sync serialize attributes of a GoogleEntity.""" + hass.states.async_set("light.ceiling_lights", "off") + hass.config.api = Mock(port=1234, use_ssl=True) + config = MockConfig( + hass=hass, + local_sdk_webhook_id="mock-webhook-id", + local_sdk_user_id="mock-user-id", + ) + entity = helpers.GoogleEntity(hass, config, hass.states.get("light.ceiling_lights")) + + serialized = await entity.sync_serialize() + assert "otherDeviceIds" not in serialized + assert "customData" not in serialized + + config.async_enable_local_sdk() + + serialized = await entity.sync_serialize() + assert serialized["otherDeviceIds"] == [{"deviceId": "light.ceiling_lights"}] + assert serialized["customData"] == { + "httpPort": 1234, + "httpSSL": True, + "proxyDeviceId": None, + "webhookId": "mock-webhook-id", + } + + +async def test_config_local_sdk(hass, hass_client): + """Test the local SDK.""" + command_events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) + turn_on_calls = async_mock_service(hass, "light", "turn_on") + hass.states.async_set("light.ceiling_lights", "off") + + assert await async_setup_component(hass, "webhook", {}) + + config = MockConfig( + hass=hass, + local_sdk_webhook_id="mock-webhook-id", + local_sdk_user_id="mock-user-id", + ) + + client = await hass_client() + + config.async_enable_local_sdk() + + resp = await client.post( + "/api/webhook/mock-webhook-id", + json={ + "inputs": [ + { + "context": {"locale_country": "US", "locale_language": "en"}, + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [{"id": "light.ceiling_lights"}], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + } + ], + } + ], + "structureData": {}, + }, + } + ], + "requestId": "mock-req-id", + }, + ) + assert resp.status == 200 + result = await resp.json() + assert result["requestId"] == "mock-req-id" + + assert len(command_events) == 1 + assert command_events[0].context.user_id == config.local_sdk_user_id + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].context is command_events[0].context + + config.async_disable_local_sdk() + + # Webhook is no longer active + resp = await client.post("/api/webhook/mock-webhook-id") + assert resp.status == 200 + assert await resp.read() == b"" + + +async def test_config_local_sdk_if_disabled(hass, hass_client): + """Test the local SDK.""" + assert await async_setup_component(hass, "webhook", {}) + + config = MockConfig( + hass=hass, + local_sdk_webhook_id="mock-webhook-id", + local_sdk_user_id="mock-user-id", + enabled=False, + ) + + client = await hass_client() + + config.async_enable_local_sdk() + + resp = await client.post( + "/api/webhook/mock-webhook-id", json={"requestId": "mock-req-id"} + ) + assert resp.status == 200 + result = await resp.json() + assert result == { + "payload": {"errorCode": "deviceTurnedOff"}, + "requestId": "mock-req-id", + } + + config.async_disable_local_sdk() + + # Webhook is no longer active + resp = await client.post("/api/webhook/mock-webhook-id") + assert resp.status == 200 + assert await resp.read() == b"" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6ecd4af446b..2f7fdb8e131 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -3,7 +3,7 @@ from unittest.mock import patch, Mock import pytest from homeassistant.core import State, EVENT_CALL_SERVICE -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, __version__ from homeassistant.setup import async_setup_component from homeassistant.components import camera from homeassistant.components.climate.const import ( @@ -734,3 +734,137 @@ async def test_trait_execute_adding_query_data(hass): ] }, } + + +async def test_identify(hass): + """Test identify message.""" + result = await sh.async_handle_message( + hass, + BASIC_CONFIG, + None, + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.IDENTIFY", + "payload": { + "device": { + "mdnsScanData": { + "additionals": [ + { + "type": "TXT", + "class": "IN", + "name": "devhome._home-assistant._tcp.local", + "ttl": 4500, + "data": [ + "version=0.101.0.dev0", + "base_url=http://192.168.1.101:8123", + "requires_api_password=true", + ], + } + ] + } + }, + "structureData": {}, + }, + } + ], + "devices": [ + { + "id": "light.ceiling_lights", + "customData": { + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": BASIC_CONFIG.agent_user_id, + "webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219", + }, + } + ], + }, + ) + + assert result == { + "requestId": REQ_ID, + "payload": { + "device": { + "id": BASIC_CONFIG.agent_user_id, + "isLocalOnly": True, + "isProxy": True, + "deviceInfo": { + "hwVersion": "UNKNOWN_HW_VERSION", + "manufacturer": "Home Assistant", + "model": "Home Assistant", + "swVersion": __version__, + }, + } + }, + } + + +async def test_reachable_devices(hass): + """Test REACHABLE_DEVICES intent.""" + # Matching passed in device. + hass.states.async_set("light.ceiling_lights", "on") + + # Unsupported entity + hass.states.async_set("not_supported.entity", "something") + + # Excluded via config + hass.states.async_set("light.not_expose", "on") + + # Not passed in as google_id + hass.states.async_set("light.not_mentioned", "on") + + config = MockConfig( + should_expose=lambda state: state.entity_id != "light.not_expose" + ) + + result = await sh.async_handle_message( + hass, + config, + None, + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.REACHABLE_DEVICES", + "payload": { + "device": { + "proxyDevice": { + "id": "6a04f0f7-6125-4356-a846-861df7e01497", + "customData": "{}", + "proxyData": "{}", + } + }, + "structureData": {}, + }, + } + ], + "devices": [ + { + "id": "light.ceiling_lights", + "customData": { + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": BASIC_CONFIG.agent_user_id, + "webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219", + }, + }, + { + "id": "light.not_expose", + "customData": { + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": BASIC_CONFIG.agent_user_id, + "webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219", + }, + }, + {"id": BASIC_CONFIG.agent_user_id, "customData": {}}, + ], + }, + ) + + assert result == { + "requestId": REQ_ID, + "payload": {"devices": [{"verificationId": "light.ceiling_lights"}]}, + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a5c527dacfe..d6ec24a7867 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -48,11 +48,11 @@ _LOGGER = logging.getLogger(__name__) REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" -BASIC_DATA = helpers.RequestData(BASIC_CONFIG, "test-agent", REQ_ID) +BASIC_DATA = helpers.RequestData(BASIC_CONFIG, "test-agent", REQ_ID, None) PIN_CONFIG = MockConfig(secure_devices_pin="1234") -PIN_DATA = helpers.RequestData(PIN_CONFIG, "test-agent", REQ_ID) +PIN_DATA = helpers.RequestData(PIN_CONFIG, "test-agent", REQ_ID, None) async def test_brightness_light(hass):