Move request sync logic into GoogleConfig (#28227)

* Move request sync logic into GoogleConfig

* Return http status code for request_sync same as cloud

* agent_user_id is not optional for async_sync_entities now

* No need in checking parameter here

* Adjust some things for cloud tests

* Adjust some more stuff for cloud tests

* Drop uneccessary else

* Black required change

* Let async_schedule_google_sync take agent_user_id

* Assert return value on api call

* Test old api key method

* Update homeassistant/components/google_assistant/helpers.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Joakim Plate 2019-11-26 22:47:13 +01:00 committed by GitHub
parent 5f1b0fb15c
commit 06231e7ac2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 121 additions and 86 deletions

View file

@ -105,7 +105,7 @@ class CloudGoogleConfig(AbstractConfig):
except ErrorResponse as err: except ErrorResponse as err:
_LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message)
async def _async_request_sync_devices(self): async def _async_request_sync_devices(self, agent_user_id: str):
"""Trigger a sync with Google.""" """Trigger a sync with Google."""
if self._sync_entities_lock.locked(): if self._sync_entities_lock.locked():
return 200 return 200
@ -143,7 +143,7 @@ class CloudGoogleConfig(AbstractConfig):
# State reporting is reported as a property on entities. # State reporting is reported as a property on entities.
# So when we change it, we need to sync all entities. # So when we change it, we need to sync all entities.
await self.async_sync_entities() await self.async_sync_entities(self.agent_user_id)
# If entity prefs are the same or we have filter in config.yaml, # If entity prefs are the same or we have filter in config.yaml,
# don't sync. # don't sync.
@ -151,7 +151,7 @@ class CloudGoogleConfig(AbstractConfig):
self._cur_entity_prefs is not prefs.google_entity_configs self._cur_entity_prefs is not prefs.google_entity_configs
and self._config["filter"].empty_filter and self._config["filter"].empty_filter
): ):
self.async_schedule_google_sync() self.async_schedule_google_sync(self.agent_user_id)
if self.enabled and not self.is_local_sdk_active: if self.enabled and not self.is_local_sdk_active:
self.async_enable_local_sdk() self.async_enable_local_sdk()
@ -167,4 +167,4 @@ class CloudGoogleConfig(AbstractConfig):
# Schedule a sync if a change was made to an entity that Google knows about # Schedule a sync if a change was made to an entity that Google knows about
if self._should_expose_entity_id(entity_id): if self._should_expose_entity_id(entity_id):
await self.async_sync_entities() await self.async_sync_entities(self.agent_user_id)

View file

@ -174,7 +174,9 @@ class GoogleActionsSyncView(HomeAssistantView):
"""Trigger a Google Actions sync.""" """Trigger a Google Actions sync."""
hass = request.app["hass"] hass = request.app["hass"]
cloud: Cloud = hass.data[DOMAIN] cloud: Cloud = hass.data[DOMAIN]
status = await cloud.client.google_config.async_sync_entities() status = await cloud.client.google_config.async_sync_entities(
cloud.client.google_config.agent_user_id
)
return self.json({}, status_code=status) return self.json({}, status_code=status)

View file

@ -1,11 +1,7 @@
"""Support for Actions on Google Assistant Smart Home Control.""" """Support for Actions on Google Assistant Smart Home Control."""
import asyncio
import logging import logging
from typing import Dict, Any from typing import Dict, Any
import aiohttp
import async_timeout
import voluptuous as vol import voluptuous as vol
# Typing imports # Typing imports
@ -13,7 +9,6 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( from .const import (
DOMAIN, DOMAIN,
@ -24,7 +19,6 @@ from .const import (
DEFAULT_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS,
CONF_API_KEY, CONF_API_KEY,
SERVICE_REQUEST_SYNC, SERVICE_REQUEST_SYNC,
REQUEST_SYNC_BASE_URL,
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_EXPOSE, CONF_EXPOSE,
CONF_ALIASES, CONF_ALIASES,
@ -99,14 +93,10 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EX
async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
"""Activate Google Actions component.""" """Activate Google Actions component."""
config = yaml_config.get(DOMAIN, {}) config = yaml_config.get(DOMAIN, {})
api_key = config.get(CONF_API_KEY) google_config = async_register_http(hass, config)
async_register_http(hass, config)
async def request_sync_service_handler(call: ServiceCall): async def request_sync_service_handler(call: ServiceCall):
"""Handle request sync service calls.""" """Handle request sync service calls."""
websession = async_get_clientsession(hass)
try:
with async_timeout.timeout(15):
agent_user_id = call.data.get("agent_user_id") or call.context.user_id agent_user_id = call.data.get("agent_user_id") or call.context.user_id
if agent_user_id is None: if agent_user_id is None:
@ -115,21 +105,10 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
) )
return return
res = await websession.post( await google_config.async_sync_entities(agent_user_id)
REQUEST_SYNC_BASE_URL,
params={"key": api_key},
json={"agent_user_id": agent_user_id},
)
_LOGGER.info("Submitted request_sync request to Google")
res.raise_for_status()
except aiohttp.ClientResponseError:
body = await res.read()
_LOGGER.error("request_sync request failed: %d %s", res.status, body)
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Could not contact Google for request_sync")
# Register service only if api key is provided # Register service only if key is provided
if api_key is not None: if CONF_API_KEY in config or CONF_SERVICE_ACCOUNT in config:
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler
) )

View file

@ -41,7 +41,7 @@ class AbstractConfig:
def __init__(self, hass): def __init__(self, hass):
"""Initialize abstract config.""" """Initialize abstract config."""
self.hass = hass self.hass = hass
self._google_sync_unsub = None self._google_sync_unsub = {}
self._local_sdk_active = False self._local_sdk_active = False
@property @property
@ -119,31 +119,29 @@ class AbstractConfig:
self._unsub_report_state() self._unsub_report_state()
self._unsub_report_state = None self._unsub_report_state = None
async def async_sync_entities(self): async def async_sync_entities(self, agent_user_id: str):
"""Sync all entities to Google.""" """Sync all entities to Google."""
# Remove any pending sync # Remove any pending sync
if self._google_sync_unsub: self._google_sync_unsub.pop(agent_user_id, lambda: None)()
self._google_sync_unsub()
self._google_sync_unsub = None
return await self._async_request_sync_devices() return await self._async_request_sync_devices(agent_user_id)
async def _schedule_callback(self, _now):
"""Handle a scheduled sync callback."""
self._google_sync_unsub = None
await self.async_sync_entities()
@callback @callback
def async_schedule_google_sync(self): def async_schedule_google_sync(self, agent_user_id: str):
"""Schedule a sync.""" """Schedule a sync."""
if self._google_sync_unsub:
self._google_sync_unsub()
self._google_sync_unsub = async_call_later( async def _schedule_callback(_now):
self.hass, SYNC_DELAY, self._schedule_callback """Handle a scheduled sync callback."""
self._google_sync_unsub.pop(agent_user_id, None)
await self.async_sync_entities(agent_user_id)
self._google_sync_unsub.pop(agent_user_id, lambda: None)()
self._google_sync_unsub[agent_user_id] = async_call_later(
self.hass, SYNC_DELAY, _schedule_callback
) )
async def _async_request_sync_devices(self) -> int: async def _async_request_sync_devices(self, agent_user_id: str) -> int:
"""Trigger a sync with Google. """Trigger a sync with Google.
Return value is the HTTP status code of the sync request. Return value is the HTTP status code of the sync request.
@ -165,7 +163,7 @@ class AbstractConfig:
return return
webhook.async_register( webhook.async_register(
self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook,
) )
self._local_sdk_active = True self._local_sdk_active = True

View file

@ -10,7 +10,7 @@ from aiohttp.web import Request, Response
# Typing imports # Typing imports
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.core import callback, ServiceCall from homeassistant.core import callback
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -27,12 +27,10 @@ from .const import (
CONF_SERVICE_ACCOUNT, CONF_SERVICE_ACCOUNT,
CONF_CLIENT_EMAIL, CONF_CLIENT_EMAIL,
CONF_PRIVATE_KEY, CONF_PRIVATE_KEY,
DOMAIN,
HOMEGRAPH_TOKEN_URL, HOMEGRAPH_TOKEN_URL,
HOMEGRAPH_SCOPE, HOMEGRAPH_SCOPE,
REPORT_STATE_BASE_URL, REPORT_STATE_BASE_URL,
REQUEST_SYNC_BASE_URL, REQUEST_SYNC_BASE_URL,
SERVICE_REQUEST_SYNC,
) )
from .smart_home import async_handle_message from .smart_home import async_handle_message
from .helpers import AbstractConfig from .helpers import AbstractConfig
@ -133,6 +131,18 @@ class GoogleConfig(AbstractConfig):
"""If an entity should have 2FA checked.""" """If an entity should have 2FA checked."""
return True return True
async def _async_request_sync_devices(self, agent_user_id: str):
if CONF_API_KEY in self._config:
await self.async_call_homegraph_api_key(
REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id}
)
elif CONF_SERVICE_ACCOUNT in self._config:
await self.async_call_homegraph_api(
REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id}
)
else:
_LOGGER.error("No configuration for request_sync available")
async def _async_update_token(self, force=False): async def _async_update_token(self, force=False):
if CONF_SERVICE_ACCOUNT not in self._config: if CONF_SERVICE_ACCOUNT not in self._config:
_LOGGER.error("Trying to get homegraph api token without service account") _LOGGER.error("Trying to get homegraph api token without service account")
@ -151,6 +161,25 @@ class GoogleConfig(AbstractConfig):
self._access_token = token["access_token"] self._access_token = token["access_token"]
self._access_token_renew = now + timedelta(seconds=token["expires_in"]) self._access_token_renew = now + timedelta(seconds=token["expires_in"])
async def async_call_homegraph_api_key(self, url, data):
"""Call a homegraph api with api key authentication."""
websession = async_get_clientsession(self.hass)
try:
res = await websession.post(
url, params={"key": self._config.get(CONF_API_KEY)}, json=data
)
_LOGGER.debug(
"Response on %s with data %s was %s", url, data, await res.text()
)
res.raise_for_status()
return res.status
except ClientResponseError as error:
_LOGGER.error("Request for %s failed: %d", url, error.status)
return error.status
except (asyncio.TimeoutError, ClientError):
_LOGGER.error("Could not contact %s", url)
return 500
async def async_call_homegraph_api(self, url, data): async def async_call_homegraph_api(self, url, data):
"""Call a homegraph api with authenticaiton.""" """Call a homegraph api with authenticaiton."""
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
@ -165,24 +194,26 @@ class GoogleConfig(AbstractConfig):
"Response on %s with data %s was %s", url, data, await res.text() "Response on %s with data %s was %s", url, data, await res.text()
) )
res.raise_for_status() res.raise_for_status()
return res.status
try: try:
await self._async_update_token() await self._async_update_token()
try: try:
await _call() return await _call()
except ClientResponseError as error: except ClientResponseError as error:
if error.status == 401: if error.status == 401:
_LOGGER.warning( _LOGGER.warning(
"Request for %s unauthorized, renewing token and retrying", url "Request for %s unauthorized, renewing token and retrying", url
) )
await self._async_update_token(True) await self._async_update_token(True)
await _call() return await _call()
else:
raise raise
except ClientResponseError as error: except ClientResponseError as error:
_LOGGER.error("Request for %s failed: %d", url, error.status) _LOGGER.error("Request for %s failed: %d", url, error.status)
return error.status
except (asyncio.TimeoutError, ClientError): except (asyncio.TimeoutError, ClientError):
_LOGGER.error("Could not contact %s", url) _LOGGER.error("Could not contact %s", url)
return 500
async def async_report_state(self, message): async def async_report_state(self, message):
"""Send a state report to Google.""" """Send a state report to Google."""
@ -201,25 +232,7 @@ def async_register_http(hass, cfg):
hass.http.register_view(GoogleAssistantView(config)) hass.http.register_view(GoogleAssistantView(config))
if config.should_report_state: if config.should_report_state:
config.async_enable_report_state() config.async_enable_report_state()
return config
async def request_sync_service_handler(call: ServiceCall):
"""Handle request sync service calls."""
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
await config.async_call_homegraph_api(
REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id}
)
# Register service only if api key is provided
if CONF_API_KEY not in cfg and CONF_SERVICE_ACCOUNT in cfg:
hass.services.async_register(
DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler
)
class GoogleAssistantView(HomeAssistantView): class GoogleAssistantView(HomeAssistantView):

View file

@ -12,7 +12,12 @@ from tests.common import mock_coro, async_fire_time_changed
async def test_google_update_report_state(hass, cloud_prefs): async def test_google_update_report_state(hass, cloud_prefs):
"""Test Google config responds to updating preference.""" """Test Google config responds to updating preference."""
config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None) config = CloudGoogleConfig(
hass,
GACTIONS_SCHEMA({}),
cloud_prefs,
Mock(claims={"cognito:username": "abcdefghjkl"}),
)
with patch.object( with patch.object(
config, "async_sync_entities", side_effect=mock_coro config, "async_sync_entities", side_effect=mock_coro
@ -39,12 +44,17 @@ async def test_sync_entities(aioclient_mock, hass, cloud_prefs):
), ),
) )
assert await config.async_sync_entities() == 404 assert await config.async_sync_entities("user") == 404
async def test_google_update_expose_trigger_sync(hass, cloud_prefs): async def test_google_update_expose_trigger_sync(hass, cloud_prefs):
"""Test Google config responds to updating exposed entities.""" """Test Google config responds to updating exposed entities."""
config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None) config = CloudGoogleConfig(
hass,
GACTIONS_SCHEMA({}),
cloud_prefs,
Mock(claims={"cognito:username": "abcdefghjkl"}),
)
with patch.object( with patch.object(
config, "async_sync_entities", side_effect=mock_coro config, "async_sync_entities", side_effect=mock_coro

View file

@ -85,14 +85,18 @@ def mock_cognito():
yield mock_cog() yield mock_cog()
async def test_google_actions_sync(mock_cognito, cloud_client, aioclient_mock): async def test_google_actions_sync(
mock_cognito, mock_cloud_login, cloud_client, aioclient_mock
):
"""Test syncing Google Actions.""" """Test syncing Google Actions."""
aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL) aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL)
req = await cloud_client.post("/api/cloud/google_actions/sync") req = await cloud_client.post("/api/cloud/google_actions/sync")
assert req.status == 200 assert req.status == 200
async def test_google_actions_sync_fails(mock_cognito, cloud_client, aioclient_mock): async def test_google_actions_sync_fails(
mock_cognito, mock_cloud_login, cloud_client, aioclient_mock
):
"""Test syncing Google Actions gone bad.""" """Test syncing Google Actions gone bad."""
aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL, status=403) aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL, status=403)
req = await cloud_client.post("/api/cloud/google_actions/sync") req = await cloud_client.post("/api/cloud/google_actions/sync")

View file

@ -106,7 +106,8 @@ async def test_call_homegraph_api(hass, aioclient_mock, hass_storage):
aioclient_mock.post(MOCK_URL, status=200, json={}) aioclient_mock.post(MOCK_URL, status=200, json={})
await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON) res = await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON)
assert res == 200
assert mock_get_token.call_count == 1 assert mock_get_token.call_count == 1
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
@ -139,6 +140,34 @@ async def test_call_homegraph_api_retry(hass, aioclient_mock, hass_storage):
assert call[3] == MOCK_HEADER assert call[3] == MOCK_HEADER
async def test_call_homegraph_api_key(hass, aioclient_mock, hass_storage):
"""Test the function to call the homegraph api."""
config = GoogleConfig(
hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"})
)
aioclient_mock.post(MOCK_URL, status=200, json={})
res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON)
assert res == 200
assert aioclient_mock.call_count == 1
call = aioclient_mock.mock_calls[0]
assert call[1].query == {"key": "dummy_key"}
assert call[2] == MOCK_JSON
async def test_call_homegraph_api_key_fail(hass, aioclient_mock, hass_storage):
"""Test the function to call the homegraph api."""
config = GoogleConfig(
hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"})
)
aioclient_mock.post(MOCK_URL, status=666, json={})
res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON)
assert res == 666
assert aioclient_mock.call_count == 1
async def test_report_state(hass, aioclient_mock, hass_storage): async def test_report_state(hass, aioclient_mock, hass_storage):
"""Test the report state function.""" """Test the report state function."""
config = GoogleConfig(hass, DUMMY_CONFIG) config = GoogleConfig(hass, DUMMY_CONFIG)