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 expiry
pull/27915/head
Joakim Plate 2019-10-19 07:37:44 +02:00 committed by Paulus Schoutsen
parent 6303117354
commit 2bd9f5680d
4 changed files with 328 additions and 2 deletions

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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},
)