diff --git a/CODEOWNERS b/CODEOWNERS index 2bfebf145df..4e7b0a0cd2a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -16,6 +16,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/adguard/* @frenck homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell +homeassistant/components/alexa/* @home-assistant/cloud homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen @@ -106,6 +107,7 @@ homeassistant/components/geonetnz_quakes/* @exxamalte homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff homeassistant/components/gntp/* @robbiet480 +homeassistant/components/google_assistant/* @home-assistant/cloud homeassistant/components/google_cloud/* @lufton homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json index c6629982d53..9db7e270e61 100644 --- a/homeassistant/components/alexa/manifest.json +++ b/homeassistant/components/alexa/manifest.json @@ -3,8 +3,6 @@ "name": "Alexa", "documentation": "https://www.home-assistant.io/integrations/alexa", "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [] + "dependencies": ["http"], + "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8b295634c99..71550fc37b1 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -34,6 +34,7 @@ from .const import ( CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, CONF_USER_POOL_ID, + CONF_GOOGLE_ACTIONS_REPORT_STATE_URL, DOMAIN, MODE_DEV, MODE_PROD, @@ -96,7 +97,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, - vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str, + vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): vol.Url(), + vol.Optional(CONF_GOOGLE_ACTIONS_REPORT_STATE_URL): vol.Url(), } ) }, diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 07882d8dac2..38ae09ced93 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -98,7 +98,7 @@ class CloudClient(Interface): if not self._google_config: assert self.cloud is not None self._google_config = google_config.CloudGoogleConfig( - self.google_user_config, self._prefs, self.cloud + self._hass, self.google_user_config, self._prefs, self.cloud ) return self._google_config @@ -107,13 +107,17 @@ class CloudClient(Interface): """Initialize the client.""" self.cloud = cloud - if not self.alexa_config.should_report_state or not self.cloud.is_logged_in: + if not self.cloud.is_logged_in: return - try: - await self.alexa_config.async_enable_proactive_mode() - except alexa_errors.NoTokenAvailable: - pass + if 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() 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 df1b8ef165d..e28d75f017d 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -9,6 +9,7 @@ PREF_GOOGLE_SECURE_DEVICES_PIN = "google_secure_devices_pin" PREF_CLOUDHOOKS = "cloudhooks" PREF_CLOUD_USER = "cloud_user" PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" +PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" PREF_OVERRIDE_NAME = "override_name" @@ -18,6 +19,7 @@ PREF_SHOULD_EXPOSE = "should_expose" DEFAULT_SHOULD_EXPOSE = True DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = False +DEFAULT_GOOGLE_REPORT_STATE = False CONF_ALEXA = "alexa" CONF_ALIASES = "aliases" @@ -33,6 +35,7 @@ CONF_CLOUDHOOK_CREATE_URL = "cloudhook_create_url" CONF_REMOTE_API_URL = "remote_api_url" CONF_ACME_DIRECTORY_SERVER = "acme_directory_server" CONF_ALEXA_ACCESS_TOKEN_URL = "alexa_access_token_url" +CONF_GOOGLE_ACTIONS_REPORT_STATE_URL = "google_actions_report_state_url" MODE_DEV = "development" MODE_PROD = "production" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 8986f8f3995..38e4aec56e0 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,6 +1,13 @@ """Google config for Cloud.""" +import asyncio +import logging + +import async_timeout +from hass_nabucasa.google_report_state import ErrorResponse + from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.helpers import entity_registry from .const import ( PREF_SHOULD_EXPOSE, @@ -10,15 +17,31 @@ from .const import ( DEFAULT_DISABLE_2FA, ) +_LOGGER = logging.getLogger(__name__) + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" - def __init__(self, config, prefs, cloud): - """Initialize the Alexa config.""" + def __init__(self, hass, config, prefs, cloud): + """Initialize the Google config.""" + super().__init__(hass) self._config = config self._prefs = prefs self._cloud = cloud + self._cur_entity_prefs = self._prefs.google_entity_configs + self._sync_entities_lock = asyncio.Lock() + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + + @property + def enabled(self): + """Return if Google is enabled.""" + return self._prefs.google_enabled @property def agent_user_id(self): @@ -35,16 +58,25 @@ class CloudGoogleConfig(AbstractConfig): """Return entity config.""" return self._prefs.google_secure_devices_pin + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._prefs.google_report_state + def should_expose(self, state): - """If an entity should be exposed.""" - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + """If a state object should be exposed.""" + return self._should_expose_entity_id(state.entity_id) + + def _should_expose_entity_id(self, entity_id): + """If an entity ID should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False if not self._config["filter"].empty_filter: - return self._config["filter"](state.entity_id) + return self._config["filter"](entity_id) entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) + entity_config = entity_configs.get(entity_id, {}) return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) def should_2fa(self, state): @@ -52,3 +84,72 @@ class CloudGoogleConfig(AbstractConfig): entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(state.entity_id, {}) return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + + async def async_report_state(self, message): + """Send a state report to Google.""" + try: + await self._cloud.google_report_state.async_send_message(message) + except ErrorResponse as err: + _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) + + async def _async_request_sync_devices(self): + """Trigger a sync with Google.""" + if self._sync_entities_lock.locked(): + return 200 + + websession = self.hass.helpers.aiohttp_client.async_get_clientsession() + + async with self._sync_entities_lock: + with async_timeout.timeout(10): + await self._cloud.auth.async_check_token() + + _LOGGER.debug("Requesting sync") + + with async_timeout.timeout(30): + req = await websession.post( + self._cloud.google_actions_sync_url, + headers={"authorization": self._cloud.id_token}, + ) + _LOGGER.debug("Finished requesting syncing: %s", req.status) + return req.status + + async def async_deactivate_report_state(self): + """Turn off report state and disable further state reporting. + + Called when the user disconnects their account from Google. + """ + await self._prefs.async_update(google_report_state=False) + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_state: + if self.should_report_state: + self.async_enable_report_state() + else: + self.async_disable_report_state() + + # 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 + ): + return + + self.async_schedule_google_sync() + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + entity_id = event.data["entity_id"] + + # Schedule a sync if a change was made to an entity that Google knows about + if self._should_expose_entity_id(entity_id): + await self.async_sync_entities() diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index fce530ddce5..f243eab8fd0 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -7,6 +7,7 @@ import attr import aiohttp import async_timeout import voluptuous as vol +from hass_nabucasa import Cloud from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView @@ -28,6 +29,7 @@ from .const import ( InvalidTrustedNetworks, InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE, + PREF_GOOGLE_REPORT_STATE, RequireRelink, ) @@ -171,18 +173,9 @@ class GoogleActionsSyncView(HomeAssistantView): 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): - await hass.async_add_job(cloud.auth.check_token) - - with async_timeout.timeout(REQUEST_TIMEOUT): - req = await websession.post( - cloud.google_actions_sync_url, headers={"authorization": cloud.id_token} - ) - - return self.json({}, status_code=req.status) + cloud: Cloud = hass.data[DOMAIN] + status = await cloud.client.google_config.async_sync_entities() + return self.json({}, status_code=status) class CloudLoginView(HomeAssistantView): @@ -366,6 +359,7 @@ async def websocket_subscription(hass, connection, msg): vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, + vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), } ) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index b15fa32cb13..c8fa6884563 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.17"], + "requirements": ["hass-nabucasa==0.22"], "dependencies": ["http", "webhook"], "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d6e78e87e25..a8ff775a227 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -20,6 +20,8 @@ from .const import ( PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE, + PREF_GOOGLE_REPORT_STATE, + DEFAULT_GOOGLE_REPORT_STATE, InvalidTrustedNetworks, InvalidTrustedProxies, ) @@ -74,6 +76,7 @@ class CloudPreferences: google_entity_configs=_UNDEF, alexa_entity_configs=_UNDEF, alexa_report_state=_UNDEF, + google_report_state=_UNDEF, ): """Update user preferences.""" for key, value in ( @@ -86,6 +89,7 @@ class CloudPreferences: (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), + (PREF_GOOGLE_REPORT_STATE, google_report_state), ): if value is not _UNDEF: self._prefs[key] = value @@ -164,6 +168,7 @@ class CloudPreferences: PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, + PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUD_USER: self.cloud_user, } @@ -196,6 +201,11 @@ class CloudPreferences: """Return if Google is enabled.""" return self._prefs[PREF_ENABLE_GOOGLE] + @property + def google_report_state(self): + """Return if Google report state is enabled.""" + return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) + @property def google_secure_devices_pin(self): """Return if Google is allowed to unlock locks.""" diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 61e0c70b6b3..a1252d67fff 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -83,6 +83,13 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): try: with async_timeout.timeout(15): agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in user id as agent_user_id." + ) + return + res = await websession.post( REQUEST_SYNC_BASE_URL, params={"key": api_key}, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index daaf790a0c1..207194d79ed 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -3,7 +3,8 @@ from asyncio import gather from collections.abc import Mapping from typing import List -from homeassistant.core import Context, callback +from homeassistant.core import Context, callback, HomeAssistant, State +from homeassistant.helpers.event import async_call_later from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, @@ -22,10 +23,24 @@ from .const import ( ) from .error import SmartHomeError +SYNC_DELAY = 15 + class AbstractConfig: """Hold the configuration for Google Assistant.""" + _unsub_report_state = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + self._google_sync_unsub = None + + @property + def enabled(self): + """Return if Google is enabled.""" + return False + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -41,6 +56,17 @@ class AbstractConfig: """Return entity config.""" return None + @property + def is_reporting_state(self): + """Return if we're actively reporting states.""" + return self._unsub_report_state is not None + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + # pylint: disable=no-self-use + return False + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" raise NotImplementedError @@ -50,11 +76,66 @@ class AbstractConfig: # pylint: disable=no-self-use return True + async def async_report_state(self, message): + """Send a state report to Google.""" + raise NotImplementedError + + def async_enable_report_state(self): + """Enable proactive mode.""" + # Circular dep + from .report_state import async_enable_report_state + + if self._unsub_report_state is None: + self._unsub_report_state = async_enable_report_state(self.hass, self) + + def async_disable_report_state(self): + """Disable report state.""" + if self._unsub_report_state is not None: + self._unsub_report_state() + self._unsub_report_state = None + + async def async_sync_entities(self): + """Sync all entities to Google.""" + # Remove any pending sync + if self._google_sync_unsub: + self._google_sync_unsub() + self._google_sync_unsub = None + + return await self._async_request_sync_devices() + + async def _schedule_callback(self, _now): + """Handle a scheduled sync callback.""" + self._google_sync_unsub = None + await self.async_sync_entities() + + @callback + def async_schedule_google_sync(self): + """Schedule a sync.""" + if self._google_sync_unsub: + self._google_sync_unsub() + + self._google_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._schedule_callback + ) + + async def _async_request_sync_devices(self) -> int: + """Trigger a sync with Google. + + Return value is the HTTP status code of the sync request. + """ + raise NotImplementedError + + async def async_deactivate_report_state(self): + """Turn off report state and disable further state reporting. + + Called when the user disconnects their account from Google. + """ + class RequestData: """Hold data associated with a particular request.""" - def __init__(self, config, user_id, request_id): + def __init__(self, config: AbstractConfig, user_id: str, request_id: str): """Initialize the request data.""" self.config = config self.request_id = request_id @@ -71,7 +152,7 @@ def get_google_type(domain, device_class): class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - def __init__(self, hass, config, state): + def __init__(self, hass: HomeAssistant, config: AbstractConfig, state: State): """Initialize a Google entity.""" self.hass = hass self.config = config @@ -139,7 +220,7 @@ class GoogleEntity: "name": {"name": name}, "attributes": {}, "traits": [trait.name for trait in traits], - "willReportState": False, + "willReportState": self.config.should_report_state, "type": device_type, } diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index d68650fb638..aea226348b8 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -25,10 +25,16 @@ _LOGGER = logging.getLogger(__name__) class GoogleConfig(AbstractConfig): """Config for manual setup of Google.""" - def __init__(self, config): + def __init__(self, hass, config): """Initialize the config.""" + super().__init__(hass) self._config = config + @property + def enabled(self): + """Return if Google is enabled.""" + return True + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -77,7 +83,7 @@ class GoogleConfig(AbstractConfig): @callback def async_register_http(hass, cfg): """Register HTTP views for Google Assistant.""" - hass.http.register_view(GoogleAssistantView(GoogleConfig(cfg))) + hass.http.register_view(GoogleAssistantView(GoogleConfig(hass, cfg))) class GoogleAssistantView(HomeAssistantView): diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json index d2e016cb5d1..f97977a7400 100644 --- a/homeassistant/components/google_assistant/manifest.json +++ b/homeassistant/components/google_assistant/manifest.json @@ -3,8 +3,6 @@ "name": "Google assistant", "documentation": "https://www.home-assistant.io/integrations/google_assistant", "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [] + "dependencies": ["http"], + "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py new file mode 100644 index 00000000000..33bb16d7830 --- /dev/null +++ b/homeassistant/components/google_assistant/report_state.py @@ -0,0 +1,39 @@ +"""Google Report State implementation.""" +from homeassistant.core import HomeAssistant, callback +from homeassistant.const import MATCH_ALL + +from .helpers import AbstractConfig, GoogleEntity + + +@callback +def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): + """Enable state reporting.""" + + async def async_entity_state_listener(changed_entity, old_state, new_state): + if not new_state: + return + + if not google_config.should_expose(new_state): + return + + entity = GoogleEntity(hass, google_config, new_state) + + if not entity.is_supported(): + return + + entity_data = entity.query_serialize() + + if old_state: + old_entity = GoogleEntity(hass, google_config, old_state) + + # Only report to Google if data that Google cares about has changed + if entity_data == old_entity.query_serialize(): + return + + await google_config.async_report_state( + {"devices": {"states": {changed_entity: entity_data}}} + ) + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 6ab6d937b51..f9b311a3880 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -193,11 +193,12 @@ async def handle_devices_execute(hass, data, payload): @HANDLERS.register("action.devices.DISCONNECT") -async def async_devices_disconnect(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 """ + await data.config.async_deactivate_report_state() return None diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29484b671ed..a64e0dc38e7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2019.6.16 contextvars==2.4;python_version<"3.7" cryptography==2.7 distro==1.4.0 -hass-nabucasa==0.17 +hass-nabucasa==0.22 home-assistant-frontend==20191002.0 importlib-metadata==0.23 jinja2>=2.10.1 diff --git a/requirements_all.txt b/requirements_all.txt index c84b57a09f3..fd44b46c64b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -607,7 +607,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.17 +hass-nabucasa==0.22 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61d3479d8f6..9bc9870be10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.17 +hass-nabucasa==0.22 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index c8e84016a28..a7c8898659a 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -59,7 +59,7 @@ async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock): cloud_prefs, Mock( alexa_access_token_url="http://example/alexa_token", - run_executor=Mock(side_effect=mock_coro), + auth=Mock(async_check_token=Mock(side_effect=mock_coro)), websession=hass.helpers.aiohttp_client.async_get_clientsession(), ), ) @@ -160,7 +160,11 @@ async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen"}, + { + "action": "update", + "entity_id": "light.kitchen", + "changes": ["entity_id"], + }, ) await hass.async_block_till_done() diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py new file mode 100644 index 00000000000..43914f489d6 --- /dev/null +++ b/tests/components/cloud/test_google_config.py @@ -0,0 +1,121 @@ +"""Test the Cloud Google Config.""" +from unittest.mock import patch, Mock + +from homeassistant.components.google_assistant import helpers as ga_helpers +from homeassistant.components.cloud import GACTIONS_SCHEMA +from homeassistant.components.cloud.google_config import CloudGoogleConfig +from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED + +from tests.common import mock_coro, async_fire_time_changed + + +async def test_google_update_report_state(hass, cloud_prefs): + """Test Google config responds to updating preference.""" + config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None) + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch( + "homeassistant.components.google_assistant.report_state.async_enable_report_state" + ) as mock_report_state: + await cloud_prefs.async_update(google_report_state=True) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 + assert len(mock_report_state.mock_calls) == 1 + + +async def test_sync_entities(aioclient_mock, hass, cloud_prefs): + """Test sync devices.""" + aioclient_mock.post("http://example.com", status=404) + config = CloudGoogleConfig( + hass, + GACTIONS_SCHEMA({}), + cloud_prefs, + Mock( + google_actions_sync_url="http://example.com", + auth=Mock(async_check_token=Mock(side_effect=mock_coro)), + ), + ) + + assert await config.async_sync_entities() == 404 + + +async def test_google_update_expose_trigger_sync(hass, cloud_prefs): + """Test Google config responds to updating exposed entities.""" + config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None) + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + await cloud_prefs.async_update_google_entity_config( + entity_id="light.kitchen", should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + await cloud_prefs.async_update_google_entity_config( + entity_id="light.kitchen", should_expose=False + ) + await cloud_prefs.async_update_google_entity_config( + entity_id="binary_sensor.door", should_expose=True + ) + await cloud_prefs.async_update_google_entity_config( + entity_id="sensor.temp", should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 + + +async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): + """Test Google config responds to entity registry.""" + config = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), cloud_prefs, hass.data["cloud"] + ) + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "create", "entity_id": "light.kitchen"}, + ) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "remove", "entity_id": "light.kitchen"}, + ) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 + + with patch.object( + config, "async_sync_entities", side_effect=mock_coro + ) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0): + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.kitchen", + "changes": ["entity_id"], + }, + ) + await hass.async_block_till_done() + + assert len(mock_sync.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d5a3395440b..8e03fb82b2c 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -33,7 +33,9 @@ SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/subscription_info" @pytest.fixture() def mock_auth(): """Mock check token.""" - with patch("hass_nabucasa.auth.CognitoAuth.check_token"): + with patch( + "hass_nabucasa.auth.CognitoAuth.async_check_token", side_effect=mock_coro + ): yield @@ -357,6 +359,7 @@ async def test_websocket_status( "google_secure_devices_pin": None, "alexa_entity_configs": {}, "alexa_report_state": False, + "google_report_state": False, "remote_enabled": False, }, "alexa_entities": { diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 244c22d2486..e160ea8826a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -28,6 +28,13 @@ async def test_constructor_loads_info_from_config(hass): "user_pool_id": "test-user_pool_id", "region": "test-region", "relayer": "test-relayer", + "google_actions_sync_url": "http://test-google_actions_sync_url", + "subscription_info_url": "http://test-subscription-info-url", + "cloudhook_create_url": "http://test-cloudhook_create_url", + "remote_api_url": "http://test-remote_api_url", + "alexa_access_token_url": "http://test-alexa-token-url", + "acme_directory_server": "http://test-acme-directory-server", + "google_actions_report_state_url": "http://test-google-actions-report-state-url", }, }, ) @@ -39,6 +46,16 @@ async def test_constructor_loads_info_from_config(hass): 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 == "http://test-google_actions_sync_url" + assert cl.subscription_info_url == "http://test-subscription-info-url" + assert cl.cloudhook_create_url == "http://test-cloudhook_create_url" + assert cl.remote_api_url == "http://test-remote_api_url" + assert cl.alexa_access_token_url == "http://test-alexa-token-url" + assert cl.acme_directory_server == "http://test-acme-directory-server" + assert ( + cl.google_actions_report_state_url + == "http://test-google-actions-report-state-url" + ) async def test_remote_services(hass, mock_cloud_fixture, hass_read_only_user): diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 12de2eaba1c..8049ac4b0db 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -6,9 +6,15 @@ class MockConfig(helpers.AbstractConfig): """Fake config that always exposes everything.""" def __init__( - self, *, secure_devices_pin=None, should_expose=None, entity_config=None + self, + *, + secure_devices_pin=None, + should_expose=None, + entity_config=None, + hass=None, ): """Initialize config.""" + super().__init__(hass) self._should_expose = should_expose self._secure_devices_pin = secure_devices_pin self._entity_config = entity_config or {} diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 1c7e0201135..9a8b9643cfe 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -1,6 +1,7 @@ """The tests for google-assistant init.""" import asyncio +from homeassistant.core import Context from homeassistant.setup import async_setup_component from homeassistant.components import google_assistant as ga @@ -20,7 +21,10 @@ def test_request_sync_service(aioclient_mock, hass): assert aioclient_mock.call_count == 0 yield from hass.services.async_call( - ga.const.DOMAIN, ga.const.SERVICE_REQUEST_SYNC, blocking=True + ga.const.DOMAIN, + ga.const.SERVICE_REQUEST_SYNC, + blocking=True, + context=Context(user_id="123"), ) assert aioclient_mock.call_count == 1 diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py new file mode 100644 index 00000000000..bd59502a3a1 --- /dev/null +++ b/tests/components/google_assistant/test_report_state.py @@ -0,0 +1,47 @@ +"""Test Google report state.""" +from unittest.mock import patch + +from homeassistant.components.google_assistant.report_state import ( + async_enable_report_state, +) +from . import BASIC_CONFIG + +from tests.common import mock_coro + + +async def test_report_state(hass): + """Test report state works.""" + unsub = async_enable_report_state(hass, BASIC_CONFIG) + + with patch.object( + BASIC_CONFIG, "async_report_state", side_effect=mock_coro + ) as mock_report: + hass.states.async_set("light.kitchen", "on") + await hass.async_block_till_done() + + assert len(mock_report.mock_calls) == 1 + assert mock_report.mock_calls[0][1][0] == { + "devices": {"states": {"light.kitchen": {"on": True, "online": True}}} + } + + # Test that state changes that change something that Google doesn't care about + # do not trigger a state report. + with patch.object( + BASIC_CONFIG, "async_report_state", side_effect=mock_coro + ) as mock_report: + hass.states.async_set( + "light.kitchen", "on", {"irrelevant": "should_be_ignored"} + ) + await hass.async_block_till_done() + + assert len(mock_report.mock_calls) == 0 + + unsub() + + with patch.object( + BASIC_CONFIG, "async_report_state", side_effect=mock_coro + ) as mock_report: + hass.states.async_set("light.kitchen", "on") + await hass.async_block_till_done() + + assert len(mock_report.mock_calls) == 0 diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6a82204a261..6ecd4af446b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -657,14 +657,20 @@ async def test_device_media_player(hass, device_class, google_type): async def test_query_disconnect(hass): """Test a disconnect message.""" - result = await sh.async_handle_message( - hass, - BASIC_CONFIG, - "test-agent", - {"inputs": [{"intent": "action.devices.DISCONNECT"}], "requestId": REQ_ID}, - ) - + config = MockConfig(hass=hass) + config.async_enable_report_state() + assert config._unsub_report_state is not None + with patch.object( + config, "async_deactivate_report_state", side_effect=mock_coro + ) as mock_deactivate: + result = await sh.async_handle_message( + hass, + config, + "test-agent", + {"inputs": [{"intent": "action.devices.DISCONNECT"}], "requestId": REQ_ID}, + ) assert result is None + assert len(mock_deactivate.mock_calls) == 1 async def test_trait_execute_adding_query_data(hass):