From d6158c0fccb57612218fde5f02587aa221e0a691 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 18 Dec 2022 00:40:24 +0200 Subject: [PATCH] Google Assistant SDK: Support non en-US language code (#84028) * Support non en-US language code * Get default language_code based on HA config * Revert bumping gassist-text Will be done in a separate PR --- .pre-commit-config.yaml | 2 +- .../google_assistant_sdk/config_flow.py | 50 ++++++++++++++++- .../components/google_assistant_sdk/const.py | 24 +++++++-- .../google_assistant_sdk/helpers.py | 22 +++++++- .../components/google_assistant_sdk/notify.py | 36 +++++++++++-- .../google_assistant_sdk/strings.json | 9 ++++ .../google_assistant_sdk/conftest.py | 12 ++++- .../google_assistant_sdk/test_config_flow.py | 54 ++++++++++++++++++- .../google_assistant_sdk/test_helpers.py | 47 ++++++++++++++++ .../google_assistant_sdk/test_init.py | 22 ++++++-- .../google_assistant_sdk/test_notify.py | 47 ++++++++++++++-- 11 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 tests/components/google_assistant_sdk/test_helpers.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cee6bad0198..b588faa8b11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=additionals,alot,ba,bre,bund,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar + - --ignore-words-list=additionals,alle,alot,ba,bre,bund,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 86a86e9ac54..b4f617ca029 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -5,11 +5,16 @@ from collections.abc import Mapping import logging from typing import Any +import voluptuous as vol + +from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_LANGUAGE_CODE, DEFAULT_NAME, DOMAIN, SUPPORTED_LANGUAGE_CODES +from .helpers import default_language_code _LOGGER = logging.getLogger(__name__) @@ -64,4 +69,45 @@ class OAuth2FlowHandler( # Config entry already exists, only one allowed. return self.async_abort(reason="single_instance_allowed") - return self.async_create_entry(title=DEFAULT_NAME, data=data) + return self.async_create_entry( + title=DEFAULT_NAME, + data=data, + options={ + CONF_LANGUAGE_CODE: default_language_code(self.hass), + }, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Google Assistant SDK options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_LANGUAGE_CODE, + default=self.config_entry.options.get(CONF_LANGUAGE_CODE), + ): vol.In(SUPPORTED_LANGUAGE_CODES), + } + ), + ) diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index 70cb3673ddc..acd9a405343 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -1,6 +1,24 @@ """Constants for Google Assistant SDK integration.""" -from __future__ import annotations +from typing import Final -DOMAIN = "google_assistant_sdk" +DOMAIN: Final = "google_assistant_sdk" -DEFAULT_NAME = "Google Assistant SDK" +DEFAULT_NAME: Final = "Google Assistant SDK" + +CONF_LANGUAGE_CODE: Final = "language_code" + +# https://developers.google.com/assistant/sdk/reference/rpc/languages +SUPPORTED_LANGUAGE_CODES: Final = [ + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "pt-BR", +] diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index 07da20a3aa0..f91f8f4241f 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -10,7 +10,16 @@ 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 +from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES + +DEFAULT_LANGUAGE_CODES = { + "de": "de-DE", + "en": "en-US", + "es": "es-ES", + "fr": "fr-FR", + "it": "it-IT", + "pt": "pt-BR", +} async def async_send_text_commands(commands: list[str], hass: HomeAssistant) -> None: @@ -27,6 +36,15 @@ async def async_send_text_commands(commands: list[str], hass: HomeAssistant) -> raise err credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) - with TextAssistant(credentials) as assistant: + language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass)) + with TextAssistant(credentials, language_code) as assistant: for command in commands: assistant.assist(command) + + +def default_language_code(hass: HomeAssistant): + """Get default language code based on Home Assistant config.""" + language_code = f"{hass.config.language}-{hass.config.country}" + if language_code in SUPPORTED_LANGUAGE_CODES: + return language_code + return DEFAULT_LANGUAGE_CODES.get(hass.config.language, "en-US") diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index c7aeaaa5355..3872a1df2a3 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -4,10 +4,32 @@ from __future__ import annotations from typing import Any from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .helpers import async_send_text_commands +from .const import CONF_LANGUAGE_CODE, DOMAIN +from .helpers import async_send_text_commands, default_language_code + +# https://support.google.com/assistant/answer/9071582?hl=en +LANG_TO_BROADCAST_COMMAND = { + "en": ("broadcast", "broadcast to"), + "de": ("Nachricht an alle", "Nachricht an alle an"), + "es": ("Anuncia", "Anuncia en"), + "fr": ("Diffuse", "Diffuse dans"), + "it": ("Trasmetti", "Trasmetti in"), + "pt": ("Transmite", "Transmite para"), +} + + +def broadcast_commands(language_code: str): + """ + Get the commands for broadcasting a message for the given language code. + + Return type is a tuple where [0] is for broadcasting to your entire home, + while [1] is for broadcasting to a specific target. + """ + return LANG_TO_BROADCAST_COMMAND.get(language_code.split("-", maxsplit=1)[0]) async def async_get_service( @@ -31,11 +53,19 @@ class BroadcastNotificationService(BaseNotificationService): if not message: return + # There can only be 1 entry (config_flow has single_instance_allowed) + entry: ConfigEntry = self.hass.config_entries.async_entries(DOMAIN)[0] + language_code = entry.options.get( + CONF_LANGUAGE_CODE, default_language_code(self.hass) + ) + commands = [] targets = kwargs.get(ATTR_TARGET) if not targets: - commands.append(f"broadcast {message}") + commands.append(f"{broadcast_commands(language_code)[0]} {message}") else: for target in targets: - commands.append(f"broadcast to {target} {message}") + commands.append( + f"{broadcast_commands(language_code)[1]} {target} {message}" + ) await async_send_text_commands(commands, self.hass) diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index d3c030645b4..66a2b975b5e 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -27,6 +27,15 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "options": { + "step": { + "init": { + "data": { + "language_code": "Language code" + } + } + } + }, "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/tests/components/google_assistant_sdk/conftest.py b/tests/components/google_assistant_sdk/conftest.py index 52d8595bc9c..9730c0fef17 100644 --- a/tests/components/google_assistant_sdk/conftest.py +++ b/tests/components/google_assistant_sdk/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable, Generator import time +from google.oauth2.credentials import Credentials import pytest from homeassistant.components.application_credentials import ( @@ -18,6 +19,7 @@ ComponentSetup = Callable[[], Awaitable[None]] CLIENT_ID = "1234" CLIENT_SECRET = "5678" +ACCESS_TOKEN = "mock-access-token" @pytest.fixture @@ -51,7 +53,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: data={ "auth_implementation": DOMAIN, "token": { - "access_token": "mock-access-token", + "access_token": ACCESS_TOKEN, "refresh_token": "mock-refresh-token", "expires_at": expires_at, "scope": " ".join(scopes), @@ -80,3 +82,11 @@ async def mock_setup_integration( await hass.async_block_till_done() yield func + + +class ExpectedCredentials: + """Assert credentials have the expected access token.""" + + def __eq__(self, other: Credentials): + """Return true if credentials have the expected access token.""" + return other.token == ACCESS_TOKEN diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index 8c723eb808a..af5f0e73c75 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.components.google_assistant_sdk.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import CLIENT_ID +from .conftest import CLIENT_ID, ComponentSetup from tests.common import MockConfigEntry @@ -205,3 +205,55 @@ async def test_single_instance_allowed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") == "abort" assert result.get("reason") == "single_instance_allowed" + + +async def test_options_flow( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration() + assert not config_entry.options + + # Trigger options flow, first time + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == {"language_code"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"language_code": "es-ES"}, + ) + assert result["type"] == "create_entry" + assert config_entry.options == {"language_code": "es-ES"} + + # Retrigger options flow, not change language + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == {"language_code"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"language_code": "es-ES"}, + ) + assert result["type"] == "create_entry" + assert config_entry.options == {"language_code": "es-ES"} + + # Retrigger options flow, change language + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == {"language_code"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"language_code": "en-US"}, + ) + assert result["type"] == "create_entry" + assert config_entry.options == {"language_code": "en-US"} diff --git a/tests/components/google_assistant_sdk/test_helpers.py b/tests/components/google_assistant_sdk/test_helpers.py new file mode 100644 index 00000000000..03a04097d67 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_helpers.py @@ -0,0 +1,47 @@ +"""Test the Google Assistant SDK helpers.""" +from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES +from homeassistant.components.google_assistant_sdk.helpers import ( + DEFAULT_LANGUAGE_CODES, + default_language_code, +) +from homeassistant.core import HomeAssistant + + +def test_default_language_codes(hass: HomeAssistant) -> None: + """Test all supported languages have a default language_code.""" + for language_code in SUPPORTED_LANGUAGE_CODES: + lang = language_code.split("-", maxsplit=1)[0] + assert DEFAULT_LANGUAGE_CODES.get(lang) + + +def test_default_language_code(hass: HomeAssistant) -> None: + """Test default_language_code.""" + assert default_language_code(hass) == "en-US" + + hass.config.language = "en" + hass.config.country = "US" + assert default_language_code(hass) == "en-US" + + hass.config.language = "en" + hass.config.country = "GB" + assert default_language_code(hass) == "en-GB" + + hass.config.language = "en" + hass.config.country = "ES" + assert default_language_code(hass) == "en-US" + + hass.config.language = "es" + hass.config.country = "ES" + assert default_language_code(hass) == "es-ES" + + hass.config.language = "es" + hass.config.country = "MX" + assert default_language_code(hass) == "es-MX" + + hass.config.language = "es" + hass.config.country = None + assert default_language_code(hass) == "es-ES" + + hass.config.language = "el" + hass.config.country = "GR" + assert default_language_code(hass) == "en-US" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 2c517a0298f..afc5e77042f 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -1,7 +1,7 @@ """Tests for Google Assistant SDK.""" import http import time -from unittest.mock import patch +from unittest.mock import call, patch import aiohttp import pytest @@ -10,7 +10,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import ComponentSetup +from .conftest import ComponentSetup, ExpectedCredentials from tests.test_util.aiohttp import AiohttpClientMocker @@ -97,9 +97,16 @@ async def test_expired_token_refresh_failure( assert entries[0].state is expected_state +@pytest.mark.parametrize( + "configured_language_code,expected_language_code", + [("", "en-US"), ("en-US", "en-US"), ("es-ES", "es-ES")], + ids=["default", "english", "spanish"], +) async def test_send_text_command( hass: HomeAssistant, setup_integration: ComponentSetup, + configured_language_code: str, + expected_language_code: str, ) -> None: """Test service call send_text_command calls TextAssistant.""" await setup_integration() @@ -107,18 +114,23 @@ async def test_send_text_command( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED + if configured_language_code: + entries[0].options = {"language_code": configured_language_code} command = "turn on home assistant unsupported device" with patch( - "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" - ) as mock_assist_call: + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant" + ) as mock_text_assistant: await hass.services.async_call( DOMAIN, "send_text_command", {"command": command}, blocking=True, ) - mock_assist_call.assert_called_once_with(command) + mock_text_assistant.assert_called_once_with( + ExpectedCredentials(), expected_language_code + ) + mock_text_assistant.assert_has_calls([call().__enter__().assist(command)]) @pytest.mark.parametrize( diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index abec6184c4f..5dbaa3aa79b 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -3,9 +3,11 @@ from unittest.mock import call, patch from homeassistant.components import notify from homeassistant.components.google_assistant_sdk import DOMAIN +from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES +from homeassistant.components.google_assistant_sdk.notify import broadcast_commands from homeassistant.core import HomeAssistant -from .conftest import ComponentSetup +from .conftest import ComponentSetup, ExpectedCredentials async def test_broadcast_no_targets( @@ -17,15 +19,16 @@ async def test_broadcast_no_targets( message = "time for dinner" expected_command = "broadcast time for dinner" with patch( - "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" - ) as mock_assist_call: + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant" + ) as mock_text_assistant: 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) + mock_text_assistant.assert_called_once_with(ExpectedCredentials(), "en-US") + mock_text_assistant.assert_has_calls([call().__enter__().assist(expected_command)]) async def test_broadcast_one_target( @@ -90,3 +93,39 @@ async def test_broadcast_empty_message( ) await hass.async_block_till_done() mock_assist_call.assert_not_called() + + +async def test_broadcast_spanish( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test broadcast in Spanish.""" + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + entry.options = {"language_code": "es-ES"} + + message = "comida" + expected_command = "Anuncia comida" + with patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant" + ) as mock_text_assistant: + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: message}, + ) + await hass.async_block_till_done() + mock_text_assistant.assert_called_once_with(ExpectedCredentials(), "es-ES") + mock_text_assistant.assert_has_calls([call().__enter__().assist(expected_command)]) + + +def test_broadcast_language_mapping( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test all supported languages have a mapped broadcast command.""" + for language_code in SUPPORTED_LANGUAGE_CODES: + cmds = broadcast_commands(language_code) + assert cmds + assert len(cmds) == 2 + assert cmds[0] + assert cmds[1]