From 3bc563f7ee9874cef06db5a82385be49f60a2789 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 18 Nov 2022 13:53:42 +0000 Subject: [PATCH 1/4] Copy google_sheets to google_assistant_sdk This is to improve diff of the next commit with the actual implementation. Commands used: cp -r homeassistant/components/google_sheets/ homeassistant/components/google_assistant_sdk/ cp -r tests/components/google_sheets/ tests/components/google_assistant_sdk/ find homeassistant/components/google_assistant_sdk/ tests/components/google_assistant_sdk/ -type f | xargs sed -i \ -e 's@google_sheets@google_assistant_sdk@g' \ -e 's@Google Sheets@Google Assistant SDK@g' \ -e 's@tkdrob@tronikos@g' --- CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../google_assistant_sdk/__init__.py | 122 ++++++ .../application_credentials.py | 27 ++ .../google_assistant_sdk/config_flow.py | 95 +++++ .../components/google_assistant_sdk/const.py | 10 + .../google_assistant_sdk/manifest.json | 11 + .../google_assistant_sdk/services.yaml | 24 ++ .../google_assistant_sdk/strings.json | 39 ++ .../google_assistant_sdk/translations/en.json | 35 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../google_assistant_sdk/__init__.py | 1 + .../google_assistant_sdk/test_config_flow.py | 379 ++++++++++++++++++ .../google_assistant_sdk/test_init.py | 289 +++++++++++++ 18 files changed, 1045 insertions(+) create mode 100644 homeassistant/components/google_assistant_sdk/__init__.py create mode 100644 homeassistant/components/google_assistant_sdk/application_credentials.py create mode 100644 homeassistant/components/google_assistant_sdk/config_flow.py create mode 100644 homeassistant/components/google_assistant_sdk/const.py create mode 100644 homeassistant/components/google_assistant_sdk/manifest.json create mode 100644 homeassistant/components/google_assistant_sdk/services.yaml create mode 100644 homeassistant/components/google_assistant_sdk/strings.json create mode 100644 homeassistant/components/google_assistant_sdk/translations/en.json create mode 100644 tests/components/google_assistant_sdk/__init__.py create mode 100644 tests/components/google_assistant_sdk/test_config_flow.py create mode 100644 tests/components/google_assistant_sdk/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6032294e4d681b..6ef5133b938c32 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,6 +428,8 @@ build.json @home-assistant/supervisor /tests/components/google/ @allenporter /homeassistant/components/google_assistant/ @home-assistant/cloud /tests/components/google_assistant/ @home-assistant/cloud +/homeassistant/components/google_assistant_sdk/ @tronikos +/tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index de27fa7c515f1a..cceda7505c6a05 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -3,6 +3,7 @@ "name": "Google", "integrations": [ "google_assistant", + "google_assistant_sdk", "google_cloud", "google_domains", "google_maps", diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py new file mode 100644 index 00000000000000..eda9cfa4f64043 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -0,0 +1,122 @@ +"""Support for Google Assistant SDK.""" +from __future__ import annotations + +from datetime import datetime + +import aiohttp +from google.auth.exceptions import RefreshError +from google.oauth2.credentials import Credentials +from gspread import Client +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ConfigEntrySelector + +from .const import DATA_CONFIG_ENTRY, DEFAULT_ACCESS, DOMAIN + +DATA = "data" +WORKSHEET = "worksheet" + +SERVICE_APPEND_SHEET = "append_sheet" + +SHEET_SERVICE_SCHEMA = vol.All( + { + vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Optional(WORKSHEET): cv.string, + vol.Required(DATA): dict, + }, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Assistant SDK from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + + if not async_entry_has_scopes(hass, entry): + raise ConfigEntryAuthFailed("Required scopes are not present, reauth required") + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session + + await async_setup_service(hass) + + return True + + +def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Verify that the config entry desired scope is present in the oauth token.""" + return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ") + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + for service_name in hass.services.async_services()[DOMAIN]: + hass.services.async_remove(DOMAIN, service_name) + + return True + + +async def async_setup_service(hass: HomeAssistant) -> None: + """Add the services for Google Assistant SDK.""" + + def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: + """Run append in the executor.""" + service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + try: + sheet = service.open_by_key(entry.unique_id) + except RefreshError as ex: + entry.async_start_reauth(hass) + raise ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) + row_data = {"created": str(datetime.now())} | call.data[DATA] + columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) + row = [row_data.get(column, "") for column in columns] + for key, value in row_data.items(): + if key not in columns: + columns.append(key) + worksheet.update_cell(1, len(columns), key) + row.append(value) + worksheet.append_row(row) + + async def append_to_sheet(call: ServiceCall) -> None: + """Append new line of data to a Google Assistant SDK document.""" + entry: ConfigEntry | None = hass.config_entries.async_get_entry( + call.data[DATA_CONFIG_ENTRY] + ) + if not entry: + raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}") + if not (session := hass.data[DOMAIN].get(entry.entry_id)): + raise ValueError(f"Config entry not loaded: {call.data[DATA_CONFIG_ENTRY]}") + await session.async_ensure_token_valid() + await hass.async_add_executor_job(_append_to_sheet, call, entry) + + hass.services.async_register( + DOMAIN, + SERVICE_APPEND_SHEET, + append_to_sheet, + schema=SHEET_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/google_assistant_sdk/application_credentials.py b/homeassistant/components/google_assistant_sdk/application_credentials.py new file mode 100644 index 00000000000000..41fc3821525090 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/application_credentials.py @@ -0,0 +1,27 @@ +"""application_credentials platform for Google Assistant SDK.""" + +import oauth2client + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +AUTHORIZATION_SERVER = AuthorizationServer( + oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI +) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + oauth2client.GOOGLE_AUTH_URI, + oauth2client.GOOGLE_TOKEN_URI, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py new file mode 100644 index 00000000000000..19f438b4b0a6d3 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for Google Assistant SDK integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from gspread import Client, GSpreadException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Assistant SDK OAuth2 authentication.""" + + DOMAIN = DOMAIN + + reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": DEFAULT_ACCESS, + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + + if self.reauth_entry: + _LOGGER.debug("service.open_by_key") + try: + await self.hass.async_add_executor_job( + service.open_by_key, + self.reauth_entry.unique_id, + ) + except GSpreadException as err: + _LOGGER.error( + "Could not find spreadsheet '%s': %s", + self.reauth_entry.unique_id, + str(err), + ) + return self.async_abort(reason="open_spreadsheet_failure") + + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + try: + doc = await self.hass.async_add_executor_job( + service.create, "Home Assistant" + ) + except GSpreadException as err: + _LOGGER.error("Error creating spreadsheet: %s", str(err)) + return self.async_abort(reason="create_spreadsheet_failure") + + await self.async_set_unique_id(doc.id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=DEFAULT_NAME, data=data, description_placeholders={"url": doc.url} + ) diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py new file mode 100644 index 00000000000000..6e545bdb4c73e6 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -0,0 +1,10 @@ +"""Constants for Google Assistant SDK integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN = "google_assistant_sdk" + +DATA_CONFIG_ENTRY: Final = "config_entry" +DEFAULT_NAME = "Google Assistant SDK" +DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file" diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json new file mode 100644 index 00000000000000..52691e4e234175 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "google_assistant_sdk", + "name": "Google Assistant SDK", + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/", + "requirements": ["gspread==5.5.0"], + "codeowners": ["@tronikos"], + "iot_class": "cloud_polling", + "integration_type": "service" +} diff --git a/homeassistant/components/google_assistant_sdk/services.yaml b/homeassistant/components/google_assistant_sdk/services.yaml new file mode 100644 index 00000000000000..c732b1f4d120c9 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/services.yaml @@ -0,0 +1,24 @@ +append_sheet: + name: Append to Sheet + description: Append data to a worksheet in Google Assistant SDK. + fields: + config_entry: + name: Sheet + description: The sheet to add data to + required: true + selector: + config_entry: + integration: google_assistant_sdk + worksheet: + name: Worksheet + description: Name of the worksheet. Defaults to the first one in the document. + example: "Sheet1" + selector: + text: + data: + name: Data + description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column. + required: true + example: '{"hello": world, "cool": True, "count": 5}' + selector: + object: diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json new file mode 100644 index 00000000000000..ba720de3624844 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Assistant SDK integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Assistant SDK integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" + }, + "create_entry": { + "default": "Successfully authenticated and spreadsheet created at: {url}" + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + } +} diff --git a/homeassistant/components/google_assistant_sdk/translations/en.json b/homeassistant/components/google_assistant_sdk/translations/en.json new file mode 100644 index 00000000000000..064dd179c3037c --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/translations/en.json @@ -0,0 +1,35 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", + "invalid_access_token": "Invalid access token", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth_error": "Received invalid token data.", + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", + "reauth_successful": "Re-authentication was successful", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "create_entry": { + "default": "Successfully authenticated and spreadsheet created at: {url}" + }, + "step": { + "auth": { + "title": "Link Google Account" + }, + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "description": "The Google Assistant SDK integration needs to re-authenticate your account", + "title": "Reauthenticate Integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 31e73418c5efd8..87813c20189b7b 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,6 +6,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", + "google_assistant_sdk", "google_sheets", "home_connect", "lametric", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5875c9021f6497..3955d695c9ea61 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -154,6 +154,7 @@ "gogogate2", "goodwe", "google", + "google_assistant_sdk", "google_sheets", "google_travel_time", "govee_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02068ecafa5e6e..f3187dc502c4ae 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1957,6 +1957,12 @@ "iot_class": "cloud_push", "name": "Google Assistant" }, + "google_assistant_sdk": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Assistant SDK" + }, "google_cloud": { "integration_type": "hub", "config_flow": false, diff --git a/requirements_all.txt b/requirements_all.txt index d08af9eba9bc15..c5befb6dde04f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -816,6 +816,7 @@ gridnet==4.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 +# homeassistant.components.google_assistant_sdk # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0072ddf9a7d257..d52bf51834b9cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -611,6 +611,7 @@ gridnet==4.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 +# homeassistant.components.google_assistant_sdk # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/tests/components/google_assistant_sdk/__init__.py b/tests/components/google_assistant_sdk/__init__.py new file mode 100644 index 00000000000000..7b5fee00ad0f7f --- /dev/null +++ b/tests/components/google_assistant_sdk/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Assistant SDK integration.""" diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py new file mode 100644 index 00000000000000..a3b1bb4ad40dec --- /dev/null +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -0,0 +1,379 @@ +"""Test the Google Assistant SDK config flow.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +from gspread import GSpreadException +import oauth2client +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_assistant_sdk.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +SHEET_ID = "google-sheet-id" +TITLE = "Google Assistant SDK" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(autouse=True) +async def mock_client() -> Generator[Mock, None, None]: + """Fixture to setup a fake spreadsheet client library.""" + with patch( + "homeassistant.components.google_assistant_sdk.config_flow.Client" + ) as mock_client: + yield mock_client + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_assistant_sdk", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare fake client library response when creating the sheet + mock_create = Mock() + mock_create.return_value.id = SHEET_ID + mock_client.return_value.create = mock_create + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_assistant_sdk.async_setup_entry", + return_value=True, + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(mock_client.mock_calls) == 2 + + assert result.get("type") == "create_entry" + assert result.get("title") == TITLE + assert "result" in result + assert result.get("result").unique_id == SHEET_ID + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +async def test_create_sheet_error( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test case where creating the spreadsheet fails.""" + result = await hass.config_entries.flow.async_init( + "google_assistant_sdk", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare fake exception creating the spreadsheet + mock_create = Mock() + mock_create.side_effect = GSpreadException() + mock_client.return_value.create = mock_create + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "create_spreadsheet_failure" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test the reauthentication case updates the existing config entry.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SHEET_ID, + data={ + "token": { + "access_token": "mock-access-token", + }, + }, + ) + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Config flow will lookup existing key to make sure it still exists + mock_open = Mock() + mock_open.return_value.id = SHEET_ID + mock_client.return_value.open_by_key = mock_open + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_assistant_sdk.async_setup_entry", + return_value=True, + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result.get("type") == "abort" + assert result.get("reason") == "reauth_successful" + + assert config_entry.unique_id == SHEET_ID + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == "updated-access-token" + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +async def test_reauth_abort( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test failure case during reauth.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SHEET_ID, + data={ + "token": { + "access_token": "mock-access-token", + }, + }, + ) + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Simulate failure looking up existing spreadsheet + mock_open = Mock() + mock_open.return_value.id = SHEET_ID + mock_open.side_effect = GSpreadException() + mock_client.return_value.open_by_key = mock_open + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "open_spreadsheet_failure" + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test case where config flow discovers unique id was already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SHEET_ID, + data={ + "token": { + "access_token": "mock-access-token", + }, + }, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "google_assistant_sdk", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare fake client library response when creating the sheet + mock_create = Mock() + mock_create.return_value.id = SHEET_ID + mock_client.return_value.create = mock_create + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py new file mode 100644 index 00000000000000..ccf73f93ce4c71 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_init.py @@ -0,0 +1,289 @@ +"""Tests for Google Assistant SDK.""" + +from collections.abc import Awaitable, Callable, Generator +import http +import time +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_assistant_sdk import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotFound +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +TEST_SHEET_ID = "google-sheet-it" + +ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return ["https://www.googleapis.com/auth/drive.file"] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SHEET_ID, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Generator[ComponentSetup, None, None]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), + DOMAIN, + ) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func + + +async def test_setup_success( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert entries[0].state is ConfigEntryState.NOT_LOADED + assert not len(hass.services.async_services().get(DOMAIN, {})) + + +@pytest.mark.parametrize( + "scopes", + [ + [], + [ + "https://www.googleapis.com/auth/drive.file+plus+extra" + ], # Required scope is a prefix + ["https://www.googleapis.com/auth/drive.readonly"], + ], + ids=["no_scope", "required_scope_prefix", "other_scope"], +) +async def test_missing_required_scopes_requires_reauth( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test that reauth is invoked when required scopes are not present.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + scopes: list[str], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + "expires_at,status,expected_state", + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + scopes: list[str], + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_append_sheet( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test service call appending to a sheet.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + with patch("homeassistant.components.google_assistant_sdk.Client") as mock_client: + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 8 + + +async def test_append_sheet_invalid_config_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + expires_at: int, + scopes: list[str], +) -> None: + """Test service call with invalid config entries.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SHEET_ID + "2", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + ) + config_entry2.add_to_hass(hass) + + await setup_integration() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry2.state is ConfigEntryState.LOADED + + # Exercise service call on a config entry that does not exist + with pytest.raises(ValueError, match="Invalid config entry"): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id + "XXX", + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + + # Unload the config entry invoke the service on the unloaded entry id + await hass.config_entries.async_unload(config_entry2.entry_id) + await hass.async_block_till_done() + assert config_entry2.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(ValueError, match="Config entry not loaded"): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry2.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + + # Unloading the other config entry will de-register the service + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(ServiceNotFound): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) From 22388b80e3b83b621c5af269d38a17c5e10aaf20 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 18 Nov 2022 14:24:49 +0000 Subject: [PATCH 2/4] Google Assistant SDK integration Allows sending commands and broadcast messages to Google Assistant. --- .../google_assistant_sdk/__init__.py | 80 +++----- .../application_credentials.py | 5 - .../google_assistant_sdk/config_flow.py | 36 +--- .../components/google_assistant_sdk/const.py | 5 +- .../google_assistant_sdk/helpers.py | 32 +++ .../google_assistant_sdk/manifest.json | 2 +- .../components/google_assistant_sdk/notify.py | 41 ++++ .../google_assistant_sdk/services.yaml | 28 +-- .../google_assistant_sdk/strings.json | 10 +- .../google_assistant_sdk/translations/en.json | 4 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- .../google_assistant_sdk/conftest.py | 82 ++++++++ .../google_assistant_sdk/test_config_flow.py | 194 +----------------- .../google_assistant_sdk/test_init.py | 188 +++++------------ .../google_assistant_sdk/test_notify.py | 92 +++++++++ 16 files changed, 355 insertions(+), 452 deletions(-) create mode 100644 homeassistant/components/google_assistant_sdk/helpers.py create mode 100644 homeassistant/components/google_assistant_sdk/notify.py create mode 100644 tests/components/google_assistant_sdk/conftest.py create mode 100644 tests/components/google_assistant_sdk/test_notify.py diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index eda9cfa4f64043..faf9b720c894bd 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -1,41 +1,45 @@ """Support for Google Assistant SDK.""" from __future__ import annotations -from datetime import datetime - import aiohttp -from google.auth.exceptions import RefreshError -from google.oauth2.credentials import Credentials -from gspread import Client import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.const import CONF_NAME, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.selector import ConfigEntrySelector - -from .const import DATA_CONFIG_ENTRY, DEFAULT_ACCESS, DOMAIN - -DATA = "data" -WORKSHEET = "worksheet" +from homeassistant.helpers.typing import ConfigType -SERVICE_APPEND_SHEET = "append_sheet" +from .const import DEFAULT_ACCESS, DOMAIN +from .helpers import async_send_text_commands -SHEET_SERVICE_SCHEMA = vol.All( +SERVICE_SEND_TEXT_COMMAND = "send_text_command" +SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND = "command" +SERVICE_SEND_TEXT_COMMAND_SCHEMA = vol.All( { - vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(), - vol.Optional(WORKSHEET): cv.string, - vol.Required(DATA): dict, + vol.Required(SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND): vol.All( + str, vol.Length(min=1) + ), }, ) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Google Assistant SDK component.""" + hass.async_create_task( + discovery.async_load_platform( + hass, Platform.NOTIFY, DOMAIN, {CONF_NAME: DOMAIN}, config + ) + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Assistant SDK from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) @@ -83,40 +87,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_service(hass: HomeAssistant) -> None: """Add the services for Google Assistant SDK.""" - def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: - """Run append in the executor.""" - service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) - try: - sheet = service.open_by_key(entry.unique_id) - except RefreshError as ex: - entry.async_start_reauth(hass) - raise ex - worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) - row_data = {"created": str(datetime.now())} | call.data[DATA] - columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) - row = [row_data.get(column, "") for column in columns] - for key, value in row_data.items(): - if key not in columns: - columns.append(key) - worksheet.update_cell(1, len(columns), key) - row.append(value) - worksheet.append_row(row) - - async def append_to_sheet(call: ServiceCall) -> None: - """Append new line of data to a Google Assistant SDK document.""" - entry: ConfigEntry | None = hass.config_entries.async_get_entry( - call.data[DATA_CONFIG_ENTRY] - ) - if not entry: - raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}") - if not (session := hass.data[DOMAIN].get(entry.entry_id)): - raise ValueError(f"Config entry not loaded: {call.data[DATA_CONFIG_ENTRY]}") - await session.async_ensure_token_valid() - await hass.async_add_executor_job(_append_to_sheet, call, entry) + async def send_text_command(call: ServiceCall) -> None: + """Send a text command to Google Assistant SDK.""" + command: str = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND] + await async_send_text_commands([command], hass) hass.services.async_register( DOMAIN, - SERVICE_APPEND_SHEET, - append_to_sheet, - schema=SHEET_SERVICE_SCHEMA, + SERVICE_SEND_TEXT_COMMAND, + send_text_command, + schema=SERVICE_SEND_TEXT_COMMAND_SCHEMA, ) diff --git a/homeassistant/components/google_assistant_sdk/application_credentials.py b/homeassistant/components/google_assistant_sdk/application_credentials.py index 41fc3821525090..5c62c9a4e001e4 100644 --- a/homeassistant/components/google_assistant_sdk/application_credentials.py +++ b/homeassistant/components/google_assistant_sdk/application_credentials.py @@ -1,14 +1,9 @@ """application_credentials platform for Google Assistant SDK.""" - import oauth2client from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -AUTHORIZATION_SERVER = AuthorizationServer( - oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI -) - async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 19f438b4b0a6d3..92c761afbe115a 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -5,11 +5,7 @@ import logging from typing import Any -from google.oauth2.credentials import Credentials -from gspread import Client, GSpreadException - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -59,37 +55,13 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" - service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])) - if self.reauth_entry: - _LOGGER.debug("service.open_by_key") - try: - await self.hass.async_add_executor_job( - service.open_by_key, - self.reauth_entry.unique_id, - ) - except GSpreadException as err: - _LOGGER.error( - "Could not find spreadsheet '%s': %s", - self.reauth_entry.unique_id, - str(err), - ) - return self.async_abort(reason="open_spreadsheet_failure") - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") - try: - doc = await self.hass.async_add_executor_job( - service.create, "Home Assistant" - ) - except GSpreadException as err: - _LOGGER.error("Error creating spreadsheet: %s", str(err)) - return self.async_abort(reason="create_spreadsheet_failure") + if self._async_current_entries(): + # Config entry already exists, only one allowed. + return self.async_abort(reason="single_instance_allowed") - await self.async_set_unique_id(doc.id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=DEFAULT_NAME, data=data, description_placeholders={"url": doc.url} - ) + return self.async_create_entry(title=DEFAULT_NAME, data=data) diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index 6e545bdb4c73e6..8d4d972f723e00 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -1,10 +1,7 @@ """Constants for Google Assistant SDK integration.""" from __future__ import annotations -from typing import Final - DOMAIN = "google_assistant_sdk" -DATA_CONFIG_ENTRY: Final = "config_entry" DEFAULT_NAME = "Google Assistant SDK" -DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file" +DEFAULT_ACCESS = "https://www.googleapis.com/auth/assistant-sdk-prototype" diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py new file mode 100644 index 00000000000000..07da20a3aa0cee --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -0,0 +1,32 @@ +"""Helper classes for Google Assistant SDK integration.""" +from __future__ import annotations + +import aiohttp +from gassist_text import TextAssistant +from google.oauth2.credentials import Credentials + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + +from .const import DOMAIN + + +async def async_send_text_commands(commands: list[str], hass: HomeAssistant) -> None: + """Send text commands to Google Assistant Service.""" + # There can only be 1 entry (config_flow has single_instance_allowed) + entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + + session: OAuth2Session = hass.data[DOMAIN].get(entry.entry_id) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + entry.async_start_reauth(hass) + raise err + + credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) + with TextAssistant(credentials) as assistant: + for command in commands: + assistant.assist(command) diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 52691e4e234175..307c468e9aacbc 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/", - "requirements": ["gspread==5.5.0"], + "requirements": ["gassist-text==0.0.3"], "codeowners": ["@tronikos"], "iot_class": "cloud_polling", "integration_type": "service" diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py new file mode 100644 index 00000000000000..c7aeaaa5355240 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -0,0 +1,41 @@ +"""Support for Google Assistant SDK broadcast notifications.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .helpers import async_send_text_commands + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> BaseNotificationService: + """Get the broadcast notification service.""" + return BroadcastNotificationService(hass) + + +class BroadcastNotificationService(BaseNotificationService): + """Implement broadcast notification service.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the service.""" + self.hass = hass + + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: + """Send a message.""" + if not message: + return + + commands = [] + targets = kwargs.get(ATTR_TARGET) + if not targets: + commands.append(f"broadcast {message}") + else: + for target in targets: + commands.append(f"broadcast to {target} {message}") + await async_send_text_commands(commands, self.hass) diff --git a/homeassistant/components/google_assistant_sdk/services.yaml b/homeassistant/components/google_assistant_sdk/services.yaml index c732b1f4d120c9..b9d4e8635de93c 100644 --- a/homeassistant/components/google_assistant_sdk/services.yaml +++ b/homeassistant/components/google_assistant_sdk/services.yaml @@ -1,24 +1,10 @@ -append_sheet: - name: Append to Sheet - description: Append data to a worksheet in Google Assistant SDK. +send_text_command: + name: Send text command + description: Send a command as a text query to Google Assistant. fields: - config_entry: - name: Sheet - description: The sheet to add data to - required: true - selector: - config_entry: - integration: google_assistant_sdk - worksheet: - name: Worksheet - description: Name of the worksheet. Defaults to the first one in the document. - example: "Sheet1" + command: + name: Command + description: Command to send to Google Assistant. + example: turn off kitchen TV selector: text: - data: - name: Data - description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column. - required: true - example: '{"hello": world, "cool": True, "count": 5}' - selector: - object: diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index ba720de3624844..d3c030645b44a3 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -4,10 +4,6 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Google Assistant SDK integration needs to re-authenticate your account" - }, "auth": { "title": "Link Google Account" }, @@ -25,12 +21,10 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { - "default": "Successfully authenticated and spreadsheet created at: {url}" + "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "application_credentials": { diff --git a/homeassistant/components/google_assistant_sdk/translations/en.json b/homeassistant/components/google_assistant_sdk/translations/en.json index 064dd179c3037c..0d15d56a57ab80 100644 --- a/homeassistant/components/google_assistant_sdk/translations/en.json +++ b/homeassistant/components/google_assistant_sdk/translations/en.json @@ -7,17 +7,15 @@ "already_configured": "Account is already configured", "already_in_progress": "Configuration flow is already in progress", "cannot_connect": "Failed to connect", - "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", "invalid_access_token": "Invalid access token", "missing_configuration": "The component is not configured. Please follow the documentation.", "oauth_error": "Received invalid token data.", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", "reauth_successful": "Re-authentication was successful", "timeout_connect": "Timeout establishing connection", "unknown": "Unexpected error" }, "create_entry": { - "default": "Successfully authenticated and spreadsheet created at: {url}" + "default": "Successfully authenticated" }, "step": { "auth": { diff --git a/requirements_all.txt b/requirements_all.txt index c5befb6dde04f8..6f273e423a7aa8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -734,6 +734,9 @@ fritzconnection==1.10.3 # homeassistant.components.google_translate gTTS==2.2.4 +# homeassistant.components.google_assistant_sdk +gassist-text==0.0.3 + # homeassistant.components.google gcal-sync==4.0.4 @@ -816,7 +819,6 @@ gridnet==4.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 -# homeassistant.components.google_assistant_sdk # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d52bf51834b9cb..cfb3841a978bf3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,6 +550,9 @@ fritzconnection==1.10.3 # homeassistant.components.google_translate gTTS==2.2.4 +# homeassistant.components.google_assistant_sdk +gassist-text==0.0.3 + # homeassistant.components.google gcal-sync==4.0.4 @@ -611,7 +614,6 @@ gridnet==4.0.0 # homeassistant.components.growatt_server growattServer==1.3.0 -# homeassistant.components.google_assistant_sdk # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/tests/components/google_assistant_sdk/conftest.py b/tests/components/google_assistant_sdk/conftest.py new file mode 100644 index 00000000000000..52d8595bc9cc41 --- /dev/null +++ b/tests/components/google_assistant_sdk/conftest.py @@ -0,0 +1,82 @@ +"""PyTest fixtures and test helpers.""" +from collections.abc import Awaitable, Callable, Generator +import time + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_assistant_sdk.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[], Awaitable[None]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return ["https://www.googleapis.com/auth/assistant-sdk-prototype"] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Generator[ComponentSetup, None, None]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), + DOMAIN, + ) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index a3b1bb4ad40dec..8c723eb808ad4e 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -1,57 +1,26 @@ """Test the Google Assistant SDK config flow.""" +from unittest.mock import patch -from collections.abc import Generator -from unittest.mock import Mock, patch - -from gspread import GSpreadException import oauth2client -import pytest from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.google_assistant_sdk.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component + +from .conftest import CLIENT_ID from tests.common import MockConfigEntry -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -SHEET_ID = "google-sheet-id" TITLE = "Google Assistant SDK" -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup credentials.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -@pytest.fixture(autouse=True) -async def mock_client() -> Generator[Mock, None, None]: - """Fixture to setup a fake spreadsheet client library.""" - with patch( - "homeassistant.components.google_assistant_sdk.config_flow.Client" - ) as mock_client: - yield mock_client - - async def test_full_flow( hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host, setup_credentials, - mock_client, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -68,7 +37,7 @@ async def test_full_flow( assert result["url"] == ( f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" ) @@ -77,11 +46,6 @@ async def test_full_flow( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - # Prepare fake client library response when creating the sheet - mock_create = Mock() - mock_create.return_value.id = SHEET_ID - mock_client.return_value.create = mock_create - aioclient_mock.post( oauth2client.GOOGLE_TOKEN_URI, json={ @@ -100,12 +64,11 @@ async def test_full_flow( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 - assert len(mock_client.mock_calls) == 2 assert result.get("type") == "create_entry" assert result.get("title") == TITLE assert "result" in result - assert result.get("result").unique_id == SHEET_ID + assert result.get("result").unique_id is None assert "token" in result.get("result").data assert result.get("result").data["token"].get("access_token") == "mock-access-token" assert ( @@ -113,71 +76,17 @@ async def test_full_flow( ) -async def test_create_sheet_error( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, - setup_credentials, - mock_client, -) -> None: - """Test case where creating the spreadsheet fails.""" - result = await hass.config_entries.flow.async_init( - "google_assistant_sdk", context={"source": config_entries.SOURCE_USER} - ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["url"] == ( - f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" - "&access_type=offline&prompt=consent" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - # Prepare fake exception creating the spreadsheet - mock_create = Mock() - mock_create.side_effect = GSpreadException() - mock_client.return_value.create = mock_create - - aioclient_mock.post( - oauth2client.GOOGLE_TOKEN_URI, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "abort" - assert result.get("reason") == "create_spreadsheet_failure" - - async def test_reauth( hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host, setup_credentials, - mock_client, ) -> None: """Test the reauthentication case updates the existing config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, - unique_id=SHEET_ID, data={ "token": { "access_token": "mock-access-token", @@ -205,7 +114,7 @@ async def test_reauth( assert result["url"] == ( f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" ) client = await hass_client_no_auth() @@ -213,11 +122,6 @@ async def test_reauth( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - # Config flow will lookup existing key to make sure it still exists - mock_open = Mock() - mock_open.return_value.id = SHEET_ID - mock_client.return_value.open_by_key = mock_open - aioclient_mock.post( oauth2client.GOOGLE_TOKEN_URI, json={ @@ -240,94 +144,23 @@ async def test_reauth( assert result.get("type") == "abort" assert result.get("reason") == "reauth_successful" - assert config_entry.unique_id == SHEET_ID + assert config_entry.unique_id is None assert "token" in config_entry.data # Verify access token is refreshed assert config_entry.data["token"].get("access_token") == "updated-access-token" assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" -async def test_reauth_abort( +async def test_single_instance_allowed( hass: HomeAssistant, hass_client_no_auth, aioclient_mock, current_request_with_host, setup_credentials, - mock_client, ) -> None: - """Test failure case during reauth.""" - + """Test case where config flow allows a single test.""" config_entry = MockConfigEntry( domain=DOMAIN, - unique_id=SHEET_ID, - data={ - "token": { - "access_token": "mock-access-token", - }, - }, - ) - config_entry.add_to_hass(hass) - - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - assert result["url"] == ( - f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" - "&access_type=offline&prompt=consent" - ) - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - # Simulate failure looking up existing spreadsheet - mock_open = Mock() - mock_open.return_value.id = SHEET_ID - mock_open.side_effect = GSpreadException() - mock_client.return_value.open_by_key = mock_open - - aioclient_mock.post( - oauth2client.GOOGLE_TOKEN_URI, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "updated-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "abort" - assert result.get("reason") == "open_spreadsheet_failure" - - -async def test_already_configured( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, - setup_credentials, - mock_client, -) -> None: - """Test case where config flow discovers unique id was already configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=SHEET_ID, data={ "token": { "access_token": "mock-access-token", @@ -350,7 +183,7 @@ async def test_already_configured( assert result["url"] == ( f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" "&access_type=offline&prompt=consent" ) @@ -359,11 +192,6 @@ async def test_already_configured( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - # Prepare fake client library response when creating the sheet - mock_create = Mock() - mock_create.return_value.id = SHEET_ID - mock_client.return_value.create = mock_create - aioclient_mock.post( oauth2client.GOOGLE_TOKEN_URI, json={ @@ -376,4 +204,4 @@ async def test_already_configured( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") == "abort" - assert result.get("reason") == "already_configured" + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index ccf73f93ce4c71..ca6caa89879278 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -1,80 +1,18 @@ """Tests for Google Assistant SDK.""" - -from collections.abc import Awaitable, Callable, Generator import http import time from unittest.mock import patch +import aiohttp import pytest -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker - -TEST_SHEET_ID = "google-sheet-it" - -ComponentSetup = Callable[[], Awaitable[None]] - - -@pytest.fixture(name="scopes") -def mock_scopes() -> list[str]: - """Fixture to set the scopes present in the OAuth token.""" - return ["https://www.googleapis.com/auth/drive.file"] - - -@pytest.fixture(name="expires_at") -def mock_expires_at() -> int: - """Fixture to set the oauth token expiration time.""" - return time.time() + 3600 - - -@pytest.fixture(name="config_entry") -def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: - """Fixture for MockConfigEntry.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_SHEET_ID, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": expires_at, - "scope": " ".join(scopes), - }, - }, - ) +from .conftest import ComponentSetup -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> Generator[ComponentSetup, None, None]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential("client-id", "client-secret"), - DOMAIN, - ) - - async def func() -> None: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - yield func +from tests.test_util.aiohttp import AiohttpClientMocker async def test_setup_success( @@ -92,7 +30,7 @@ async def test_setup_success( assert not hass.data.get(DOMAIN) assert entries[0].state is ConfigEntryState.NOT_LOADED - assert not len(hass.services.async_services().get(DOMAIN, {})) + assert not hass.services.async_services().get(DOMAIN, {}) @pytest.mark.parametrize( @@ -100,7 +38,7 @@ async def test_setup_success( [ [], [ - "https://www.googleapis.com/auth/drive.file+plus+extra" + "https://www.googleapis.com/auth/assistant-sdk-prototype+plus+extra" ], # Required scope is a prefix ["https://www.googleapis.com/auth/drive.readonly"], ], @@ -125,7 +63,6 @@ async def test_missing_required_scopes_requires_reauth( async def test_expired_token_refresh_success( hass: HomeAssistant, setup_integration: ComponentSetup, - scopes: list[str], aioclient_mock: AiohttpClientMocker, ) -> None: """Test expired token is refreshed.""" @@ -168,7 +105,6 @@ async def test_expired_token_refresh_success( async def test_expired_token_refresh_failure( hass: HomeAssistant, setup_integration: ComponentSetup, - scopes: list[str], aioclient_mock: AiohttpClientMocker, status: http.HTTPStatus, expected_state: ConfigEntryState, @@ -187,103 +123,71 @@ async def test_expired_token_refresh_failure( assert entries[0].state is expected_state -async def test_append_sheet( +async def test_send_text_command( hass: HomeAssistant, setup_integration: ComponentSetup, - config_entry: MockConfigEntry, ) -> None: - """Test service call appending to a sheet.""" + """Test service call send_text_command calls TextAssistant.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED - with patch("homeassistant.components.google_assistant_sdk.Client") as mock_client: + command = "turn on home assistant unsupported device" + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" + ) as mock_assist_call: await hass.services.async_call( DOMAIN, - "append_sheet", - { - "config_entry": config_entry.entry_id, - "worksheet": "Sheet1", - "data": {"foo": "bar"}, - }, + "send_text_command", + {"command": command}, blocking=True, ) - assert len(mock_client.mock_calls) == 8 + mock_assist_call.assert_called_once_with(command) -async def test_append_sheet_invalid_config_entry( +@pytest.mark.parametrize( + "status,requires_reauth", + [ + ( + http.HTTPStatus.UNAUTHORIZED, + True, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + False, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_send_text_command_expired_token_refresh_failure( hass: HomeAssistant, setup_integration: ComponentSetup, - config_entry: MockConfigEntry, - expires_at: int, - scopes: list[str], + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + requires_reauth: ConfigEntryState, ) -> None: - """Test service call with invalid config entries.""" - config_entry2 = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_SHEET_ID + "2", - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": expires_at, - "scope": " ".join(scopes), - }, - }, - ) - config_entry2.add_to_hass(hass) - + """Test failure refreshing token in send_text_command.""" await setup_integration() - assert config_entry.state is ConfigEntryState.LOADED - assert config_entry2.state is ConfigEntryState.LOADED - - # Exercise service call on a config entry that does not exist - with pytest.raises(ValueError, match="Invalid config entry"): - await hass.services.async_call( - DOMAIN, - "append_sheet", - { - "config_entry": config_entry.entry_id + "XXX", - "worksheet": "Sheet1", - "data": {"foo": "bar"}, - }, - blocking=True, - ) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED - # Unload the config entry invoke the service on the unloaded entry id - await hass.config_entries.async_unload(config_entry2.entry_id) - await hass.async_block_till_done() - assert config_entry2.state is ConfigEntryState.NOT_LOADED + entry.data["token"]["expires_at"] = time.time() - 3600 + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) - with pytest.raises(ValueError, match="Config entry not loaded"): + with pytest.raises(aiohttp.ClientResponseError): await hass.services.async_call( DOMAIN, - "append_sheet", - { - "config_entry": config_entry2.entry_id, - "worksheet": "Sheet1", - "data": {"foo": "bar"}, - }, + "send_text_command", + {"command": "turn on tv"}, blocking=True, ) - # Unloading the other config entry will de-register the service - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED - - with pytest.raises(ServiceNotFound): - await hass.services.async_call( - DOMAIN, - "append_sheet", - { - "config_entry": config_entry.entry_id, - "worksheet": "Sheet1", - "data": {"foo": "bar"}, - }, - blocking=True, - ) + assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py new file mode 100644 index 00000000000000..abec6184c4ff10 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -0,0 +1,92 @@ +"""Tests for the Google Assistant notify.""" +from unittest.mock import call, patch + +from homeassistant.components import notify +from homeassistant.components.google_assistant_sdk import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + + +async def test_broadcast_no_targets( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test broadcast to all.""" + await setup_integration() + + message = "time for dinner" + expected_command = "broadcast time for dinner" + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" + ) as mock_assist_call: + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: message}, + ) + await hass.async_block_till_done() + mock_assist_call.assert_called_once_with(expected_command) + + +async def test_broadcast_one_target( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test broadcast to one target.""" + await setup_integration() + + message = "time for dinner" + target = "basement" + expected_command = "broadcast to basement time for dinner" + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" + ) as mock_assist_call: + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target]}, + ) + await hass.async_block_till_done() + mock_assist_call.assert_called_once_with(expected_command) + + +async def test_broadcast_two_targets( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test broadcast to two targets.""" + await setup_integration() + + message = "time for dinner" + target1 = "basement" + target2 = "master bedroom" + expected_command1 = "broadcast to basement time for dinner" + expected_command2 = "broadcast to master bedroom time for dinner" + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" + ) as mock_assist_call: + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target1, target2]}, + ) + await hass.async_block_till_done() + mock_assist_call.assert_has_calls( + [call(expected_command1), call(expected_command2)] + ) + + +async def test_broadcast_empty_message( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test broadcast empty message.""" + await setup_integration() + + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" + ) as mock_assist_call: + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: ""}, + ) + await hass.async_block_till_done() + mock_assist_call.assert_not_called() From 75da4b22953f3345740088f3cc352ae85d48e28d Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 8 Dec 2022 04:25:33 +0000 Subject: [PATCH 3/4] Remove unnecessary async_entry_has_scopes check --- .../google_assistant_sdk/__init__.py | 12 ++------- .../google_assistant_sdk/config_flow.py | 4 +-- .../components/google_assistant_sdk/const.py | 1 - .../google_assistant_sdk/test_init.py | 26 ------------------- 4 files changed, 4 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index faf9b720c894bd..119ba9e1d27c62 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_TOKEN, Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import discovery @@ -15,7 +15,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_ACCESS, DOMAIN +from .const import DOMAIN from .helpers import async_send_text_commands SERVICE_SEND_TEXT_COMMAND = "send_text_command" @@ -54,9 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - - if not async_entry_has_scopes(hass, entry): - raise ConfigEntryAuthFailed("Required scopes are not present, reauth required") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session await async_setup_service(hass) @@ -64,11 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Verify that the config entry desired scope is present in the oauth token.""" - return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ") - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 92c761afbe115a..86a86e9ac54b93 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def logger(self) -> logging.Logger: def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return { - "scope": DEFAULT_ACCESS, + "scope": "https://www.googleapis.com/auth/assistant-sdk-prototype", # Add params to ensure we get back a refresh token "access_type": "offline", "prompt": "consent", diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index 8d4d972f723e00..70cb3673ddc4b7 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -4,4 +4,3 @@ DOMAIN = "google_assistant_sdk" DEFAULT_NAME = "Google Assistant SDK" -DEFAULT_ACCESS = "https://www.googleapis.com/auth/assistant-sdk-prototype" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index ca6caa89879278..2c517a0298f507 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -33,32 +33,6 @@ async def test_setup_success( assert not hass.services.async_services().get(DOMAIN, {}) -@pytest.mark.parametrize( - "scopes", - [ - [], - [ - "https://www.googleapis.com/auth/assistant-sdk-prototype+plus+extra" - ], # Required scope is a prefix - ["https://www.googleapis.com/auth/drive.readonly"], - ], - ids=["no_scope", "required_scope_prefix", "other_scope"], -) -async def test_missing_required_scopes_requires_reauth( - hass: HomeAssistant, setup_integration: ComponentSetup -) -> None: - """Test that reauth is invoked when required scopes are not present.""" - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" - - @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) async def test_expired_token_refresh_success( hass: HomeAssistant, From 6af90fe5b389e3f79c5a1dbaec0ed2b2d349f3e2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 9 Dec 2022 06:55:02 +0000 Subject: [PATCH 4/4] Bump gassist-text to fix protobuf dependency --- homeassistant/components/google_assistant_sdk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 307c468e9aacbc..96517b8c0ec112 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/", - "requirements": ["gassist-text==0.0.3"], + "requirements": ["gassist-text==0.0.4"], "codeowners": ["@tronikos"], "iot_class": "cloud_polling", "integration_type": "service" diff --git a/requirements_all.txt b/requirements_all.txt index 6f273e423a7aa8..92265623f8486a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ fritzconnection==1.10.3 gTTS==2.2.4 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.3 +gassist-text==0.0.4 # homeassistant.components.google gcal-sync==4.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfb3841a978bf3..9c917dd3b39cb4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -551,7 +551,7 @@ fritzconnection==1.10.3 gTTS==2.2.4 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.3 +gassist-text==0.0.4 # homeassistant.components.google gcal-sync==4.0.4