Report state (#27759)
* Add report state config * Add initial steps for local report state * Use owner of system as user_id * First working prototype * Only report state if requested * Add some good logging and adjust constant name * Move jwt generation out to non member * Move cache out to caller * Remove todo about cache * Move getting token out of class * Add timeout on calls * Validate config dependency * Support using service key to do sync call when api_key is not set * Make sure timezone is fixed for datetime dummy * Use exact expire_in time * Support renewing token on 401 * Test retry on 401 * No need to declare dummy key twice * Correct some docs on functions * Add test for token expirypull/27915/head
parent
6303117354
commit
2bd9f5680d
|
@ -28,9 +28,13 @@ from .const import (
|
|||
CONF_ENTITY_CONFIG,
|
||||
CONF_EXPOSE,
|
||||
CONF_ALIASES,
|
||||
CONF_REPORT_STATE,
|
||||
CONF_ROOM_HINT,
|
||||
CONF_ALLOW_UNLOCK,
|
||||
CONF_SECURE_DEVICES_PIN,
|
||||
CONF_SERVICE_ACCOUNT,
|
||||
CONF_CLIENT_EMAIL,
|
||||
CONF_PRIVATE_KEY,
|
||||
)
|
||||
from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401
|
||||
from .const import EVENT_QUERY_RECEIVED # noqa: F401
|
||||
|
@ -47,6 +51,24 @@ ENTITY_SCHEMA = vol.Schema(
|
|||
}
|
||||
)
|
||||
|
||||
GOOGLE_SERVICE_ACCOUNT = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PRIVATE_KEY): cv.string,
|
||||
vol.Required(CONF_CLIENT_EMAIL): cv.string,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _check_report_state(data):
|
||||
if data[CONF_REPORT_STATE]:
|
||||
if CONF_SERVICE_ACCOUNT not in data:
|
||||
raise vol.Invalid(
|
||||
"If report state is enabled, a service account must exist"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
GOOGLE_ASSISTANT_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version="0.95"),
|
||||
vol.Schema(
|
||||
|
@ -63,9 +85,12 @@ GOOGLE_ASSISTANT_SCHEMA = vol.All(
|
|||
vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean,
|
||||
# str on purpose, makes sure it is configured correctly.
|
||||
vol.Optional(CONF_SECURE_DEVICES_PIN): str,
|
||||
vol.Optional(CONF_REPORT_STATE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SERVICE_ACCOUNT): GOOGLE_SERVICE_ACCOUNT,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
),
|
||||
_check_report_state,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
||||
|
|
|
@ -32,6 +32,10 @@ CONF_API_KEY = "api_key"
|
|||
CONF_ROOM_HINT = "room"
|
||||
CONF_ALLOW_UNLOCK = "allow_unlock"
|
||||
CONF_SECURE_DEVICES_PIN = "secure_devices_pin"
|
||||
CONF_REPORT_STATE = "report_state"
|
||||
CONF_SERVICE_ACCOUNT = "service_account"
|
||||
CONF_CLIENT_EMAIL = "client_email"
|
||||
CONF_PRIVATE_KEY = "private_key"
|
||||
|
||||
DEFAULT_EXPOSE_BY_DEFAULT = True
|
||||
DEFAULT_EXPOSED_DOMAINS = [
|
||||
|
@ -72,7 +76,10 @@ TYPE_ALARM = PREFIX_TYPES + "SECURITYSYSTEM"
|
|||
|
||||
SERVICE_REQUEST_SYNC = "request_sync"
|
||||
HOMEGRAPH_URL = "https://homegraph.googleapis.com/"
|
||||
HOMEGRAPH_SCOPE = "https://www.googleapis.com/auth/homegraph"
|
||||
HOMEGRAPH_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"
|
||||
REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + "v1/devices:requestSync"
|
||||
REPORT_STATE_BASE_URL = HOMEGRAPH_URL + "v1/devices:reportStateAndNotification"
|
||||
|
||||
# Error codes used for SmartHomeError class
|
||||
# https://developers.google.com/actions/reference/smarthome/errors-exceptions
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
"""Support for Google Actions Smart Home Control."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import jwt
|
||||
|
||||
from aiohttp import ClientResponseError, ClientError
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
# Typing imports
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import callback, ServiceCall
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||
CONF_API_KEY,
|
||||
CONF_EXPOSE_BY_DEFAULT,
|
||||
CONF_EXPOSED_DOMAINS,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_EXPOSE,
|
||||
CONF_REPORT_STATE,
|
||||
CONF_SECURE_DEVICES_PIN,
|
||||
CONF_SERVICE_ACCOUNT,
|
||||
CONF_CLIENT_EMAIL,
|
||||
CONF_PRIVATE_KEY,
|
||||
DOMAIN,
|
||||
HOMEGRAPH_TOKEN_URL,
|
||||
HOMEGRAPH_SCOPE,
|
||||
REPORT_STATE_BASE_URL,
|
||||
REQUEST_SYNC_BASE_URL,
|
||||
SERVICE_REQUEST_SYNC,
|
||||
)
|
||||
from .smart_home import async_handle_message
|
||||
from .helpers import AbstractConfig
|
||||
|
@ -22,6 +40,35 @@ from .helpers import AbstractConfig
|
|||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_homegraph_jwt(time, iss, key):
|
||||
now = int(time.timestamp())
|
||||
|
||||
jwt_raw = {
|
||||
"iss": iss,
|
||||
"scope": HOMEGRAPH_SCOPE,
|
||||
"aud": HOMEGRAPH_TOKEN_URL,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
}
|
||||
return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8")
|
||||
|
||||
|
||||
async def _get_homegraph_token(hass, jwt_signed):
|
||||
headers = {
|
||||
"Authorization": "Bearer {}".format(jwt_signed),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
data = {
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": jwt_signed,
|
||||
}
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
async with session.post(HOMEGRAPH_TOKEN_URL, headers=headers, data=data) as res:
|
||||
res.raise_for_status()
|
||||
return await res.json()
|
||||
|
||||
|
||||
class GoogleConfig(AbstractConfig):
|
||||
"""Config for manual setup of Google."""
|
||||
|
||||
|
@ -29,6 +76,8 @@ class GoogleConfig(AbstractConfig):
|
|||
"""Initialize the config."""
|
||||
super().__init__(hass)
|
||||
self._config = config
|
||||
self._access_token = None
|
||||
self._access_token_renew = None
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
|
@ -50,6 +99,12 @@ class GoogleConfig(AbstractConfig):
|
|||
"""Return entity config."""
|
||||
return self._config.get(CONF_SECURE_DEVICES_PIN)
|
||||
|
||||
@property
|
||||
def should_report_state(self):
|
||||
"""Return if states should be proactively reported."""
|
||||
# pylint: disable=no-self-use
|
||||
return self._config.get(CONF_REPORT_STATE)
|
||||
|
||||
def should_expose(self, state) -> bool:
|
||||
"""Return if entity should be exposed."""
|
||||
expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT)
|
||||
|
@ -79,11 +134,93 @@ class GoogleConfig(AbstractConfig):
|
|||
"""If an entity should have 2FA checked."""
|
||||
return True
|
||||
|
||||
async def _async_update_token(self, force=False):
|
||||
if CONF_SERVICE_ACCOUNT not in self._config:
|
||||
_LOGGER.error("Trying to get homegraph api token without service account")
|
||||
return
|
||||
|
||||
now = dt_util.utcnow()
|
||||
if not self._access_token or now > self._access_token_renew or force:
|
||||
token = await _get_homegraph_token(
|
||||
self.hass,
|
||||
_get_homegraph_jwt(
|
||||
now,
|
||||
self._config[CONF_SERVICE_ACCOUNT][CONF_CLIENT_EMAIL],
|
||||
self._config[CONF_SERVICE_ACCOUNT][CONF_PRIVATE_KEY],
|
||||
),
|
||||
)
|
||||
self._access_token = token["access_token"]
|
||||
self._access_token_renew = now + timedelta(seconds=token["expires_in"])
|
||||
|
||||
async def async_call_homegraph_api(self, url, data):
|
||||
"""Call a homegraph api with authenticaiton."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
async def _call():
|
||||
headers = {
|
||||
"Authorization": "Bearer {}".format(self._access_token),
|
||||
"X-GFE-SSL": "yes",
|
||||
}
|
||||
async with session.post(url, headers=headers, json=data) as res:
|
||||
_LOGGER.debug(
|
||||
"Response on %s with data %s was %s", url, data, await res.text()
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
try:
|
||||
await self._async_update_token()
|
||||
try:
|
||||
await _call()
|
||||
except ClientResponseError as error:
|
||||
if error.status == 401:
|
||||
_LOGGER.warning(
|
||||
"Request for %s unauthorized, renewing token and retrying", url
|
||||
)
|
||||
await self._async_update_token(True)
|
||||
await _call()
|
||||
else:
|
||||
raise
|
||||
except ClientResponseError as error:
|
||||
_LOGGER.error("Request for %s failed: %d", url, error.status)
|
||||
except (asyncio.TimeoutError, ClientError):
|
||||
_LOGGER.error("Could not contact %s", url)
|
||||
|
||||
async def async_report_state(self, message):
|
||||
"""Send a state report to Google."""
|
||||
data = {
|
||||
"requestId": uuid4().hex,
|
||||
"agentUserId": (await self.hass.auth.async_get_owner()).id,
|
||||
"payload": message,
|
||||
}
|
||||
await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_http(hass, cfg):
|
||||
"""Register HTTP views for Google Assistant."""
|
||||
hass.http.register_view(GoogleAssistantView(GoogleConfig(hass, cfg)))
|
||||
config = GoogleConfig(hass, cfg)
|
||||
hass.http.register_view(GoogleAssistantView(config))
|
||||
if config.should_report_state:
|
||||
config.async_enable_report_state()
|
||||
|
||||
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):
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
"""Test Google http services."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from asynctest import patch, ANY
|
||||
|
||||
from homeassistant.components.google_assistant.http import (
|
||||
GoogleConfig,
|
||||
_get_homegraph_jwt,
|
||||
_get_homegraph_token,
|
||||
)
|
||||
from homeassistant.components.google_assistant import GOOGLE_ASSISTANT_SCHEMA
|
||||
from homeassistant.components.google_assistant.const import (
|
||||
REPORT_STATE_BASE_URL,
|
||||
HOMEGRAPH_TOKEN_URL,
|
||||
)
|
||||
from homeassistant.auth.models import User
|
||||
|
||||
DUMMY_CONFIG = GOOGLE_ASSISTANT_SCHEMA(
|
||||
{
|
||||
"project_id": "1234",
|
||||
"service_account": {
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "dummy@dummy.iam.gserviceaccount.com",
|
||||
},
|
||||
}
|
||||
)
|
||||
MOCK_TOKEN = {"access_token": "dummtoken", "expires_in": 3600}
|
||||
MOCK_JSON = {"devices": {}}
|
||||
MOCK_URL = "https://dummy"
|
||||
MOCK_HEADER = {
|
||||
"Authorization": "Bearer {}".format(MOCK_TOKEN["access_token"]),
|
||||
"X-GFE-SSL": "yes",
|
||||
}
|
||||
|
||||
|
||||
async def test_get_jwt(hass):
|
||||
"""Test signing of key."""
|
||||
|
||||
jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsInNjb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21lZ3JhcGgiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvdG9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxNDgwMH0.gG06SmY-zSvFwSrdFfqIdC6AnC22rwz-d2F2UDeWbywjdmFL_1zceL-OOLBwjD8MJr6nR0kmN_Osu7ml9-EzzZjJqsRUxMjGn2G8nSYHbv16R4FYIp62Ibvt6Jj_wdFobEPoy_5OJ28P5Hdu0giGMlFBJMy0Tc6MgEDZA-cwOBw"
|
||||
res = _get_homegraph_jwt(
|
||||
datetime(2019, 10, 14, tzinfo=timezone.utc),
|
||||
DUMMY_CONFIG["service_account"]["client_email"],
|
||||
DUMMY_CONFIG["service_account"]["private_key"],
|
||||
)
|
||||
assert res == jwt
|
||||
|
||||
|
||||
async def test_get_access_token(hass, aioclient_mock):
|
||||
"""Test the function to get access token."""
|
||||
jwt = "dummyjwt"
|
||||
|
||||
aioclient_mock.post(
|
||||
HOMEGRAPH_TOKEN_URL,
|
||||
status=200,
|
||||
json={"access_token": "1234", "expires_in": 3600},
|
||||
)
|
||||
|
||||
await _get_homegraph_token(hass, jwt)
|
||||
assert aioclient_mock.call_count == 1
|
||||
assert aioclient_mock.mock_calls[0][3] == {
|
||||
"Authorization": "Bearer {}".format(jwt),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
|
||||
async def test_update_access_token(hass):
|
||||
"""Test the function to update access token when expired."""
|
||||
jwt = "dummyjwt"
|
||||
|
||||
config = GoogleConfig(hass, DUMMY_CONFIG)
|
||||
|
||||
base_time = datetime(2019, 10, 14, tzinfo=timezone.utc)
|
||||
with patch(
|
||||
"homeassistant.components.google_assistant.http._get_homegraph_token"
|
||||
) as mock_get_token, patch(
|
||||
"homeassistant.components.google_assistant.http._get_homegraph_jwt"
|
||||
) as mock_get_jwt, patch(
|
||||
"homeassistant.core.dt_util.utcnow"
|
||||
) as mock_utcnow:
|
||||
mock_utcnow.return_value = base_time
|
||||
mock_get_jwt.return_value = jwt
|
||||
mock_get_token.return_value = MOCK_TOKEN
|
||||
|
||||
await config._async_update_token()
|
||||
mock_get_token.assert_called_once()
|
||||
|
||||
mock_get_token.reset_mock()
|
||||
|
||||
mock_utcnow.return_value = base_time + timedelta(seconds=3600)
|
||||
await config._async_update_token()
|
||||
mock_get_token.assert_not_called()
|
||||
|
||||
mock_get_token.reset_mock()
|
||||
|
||||
mock_utcnow.return_value = base_time + timedelta(seconds=3601)
|
||||
await config._async_update_token()
|
||||
mock_get_token.assert_called_once()
|
||||
|
||||
|
||||
async def test_call_homegraph_api(hass, aioclient_mock, hass_storage):
|
||||
"""Test the function to call the homegraph api."""
|
||||
config = GoogleConfig(hass, DUMMY_CONFIG)
|
||||
with patch(
|
||||
"homeassistant.components.google_assistant.http._get_homegraph_token"
|
||||
) as mock_get_token:
|
||||
mock_get_token.return_value = MOCK_TOKEN
|
||||
|
||||
aioclient_mock.post(MOCK_URL, status=200, json={})
|
||||
|
||||
await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON)
|
||||
|
||||
assert mock_get_token.call_count == 1
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
call = aioclient_mock.mock_calls[0]
|
||||
assert call[2] == MOCK_JSON
|
||||
assert call[3] == MOCK_HEADER
|
||||
|
||||
|
||||
async def test_call_homegraph_api_retry(hass, aioclient_mock, hass_storage):
|
||||
"""Test the that the calls get retried with new token on 401."""
|
||||
config = GoogleConfig(hass, DUMMY_CONFIG)
|
||||
with patch(
|
||||
"homeassistant.components.google_assistant.http._get_homegraph_token"
|
||||
) as mock_get_token:
|
||||
mock_get_token.return_value = MOCK_TOKEN
|
||||
|
||||
aioclient_mock.post(MOCK_URL, status=401, json={})
|
||||
|
||||
await config.async_call_homegraph_api(MOCK_URL, MOCK_JSON)
|
||||
|
||||
assert mock_get_token.call_count == 2
|
||||
assert aioclient_mock.call_count == 2
|
||||
|
||||
call = aioclient_mock.mock_calls[0]
|
||||
assert call[2] == MOCK_JSON
|
||||
assert call[3] == MOCK_HEADER
|
||||
call = aioclient_mock.mock_calls[1]
|
||||
assert call[2] == MOCK_JSON
|
||||
assert call[3] == MOCK_HEADER
|
||||
|
||||
|
||||
async def test_report_state(hass, aioclient_mock, hass_storage):
|
||||
"""Test the report state function."""
|
||||
config = GoogleConfig(hass, DUMMY_CONFIG)
|
||||
message = {"devices": {}}
|
||||
owner = User(name="Test User", perm_lookup=None, groups=[], is_owner=True)
|
||||
|
||||
with patch.object(config, "async_call_homegraph_api") as mock_call, patch.object(
|
||||
hass.auth, "async_get_owner"
|
||||
) as mock_get_owner:
|
||||
mock_get_owner.return_value = owner
|
||||
|
||||
await config.async_report_state(message)
|
||||
mock_call.assert_called_once_with(
|
||||
REPORT_STATE_BASE_URL,
|
||||
{"requestId": ANY, "agentUserId": owner.id, "payload": message},
|
||||
)
|
Loading…
Reference in New Issue