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
This commit is contained in:
tronikos 2022-12-18 00:40:24 +02:00 committed by GitHub
parent ed8aa51c76
commit d6158c0fcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 303 additions and 22 deletions

View File

@ -23,7 +23,7 @@ repos:
hooks: hooks:
- id: codespell - id: codespell
args: 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" - --skip="./.*,*.csv,*.json"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json] exclude_types: [csv, json]

View File

@ -5,11 +5,16 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow 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__) _LOGGER = logging.getLogger(__name__)
@ -64,4 +69,45 @@ class OAuth2FlowHandler(
# Config entry already exists, only one allowed. # Config entry already exists, only one allowed.
return self.async_abort(reason="single_instance_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),
}
),
)

View File

@ -1,6 +1,24 @@
"""Constants for Google Assistant SDK integration.""" """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",
]

View File

@ -10,7 +10,16 @@ from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session 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: 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 raise err
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) 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: for command in commands:
assistant.assist(command) 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")

View File

@ -4,10 +4,32 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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( async def async_get_service(
@ -31,11 +53,19 @@ class BroadcastNotificationService(BaseNotificationService):
if not message: if not message:
return 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 = [] commands = []
targets = kwargs.get(ATTR_TARGET) targets = kwargs.get(ATTR_TARGET)
if not targets: if not targets:
commands.append(f"broadcast {message}") commands.append(f"{broadcast_commands(language_code)[0]} {message}")
else: else:
for target in targets: 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) await async_send_text_commands(commands, self.hass)

View File

@ -27,6 +27,15 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"
} }
}, },
"options": {
"step": {
"init": {
"data": {
"language_code": "Language code"
}
}
}
},
"application_credentials": { "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" "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"
} }

View File

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable, Generator
import time import time
from google.oauth2.credentials import Credentials
import pytest import pytest
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
@ -18,6 +19,7 @@ ComponentSetup = Callable[[], Awaitable[None]]
CLIENT_ID = "1234" CLIENT_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
ACCESS_TOKEN = "mock-access-token"
@pytest.fixture @pytest.fixture
@ -51,7 +53,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
data={ data={
"auth_implementation": DOMAIN, "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": "mock-access-token", "access_token": ACCESS_TOKEN,
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"expires_at": expires_at, "expires_at": expires_at,
"scope": " ".join(scopes), "scope": " ".join(scopes),
@ -80,3 +82,11 @@ async def mock_setup_integration(
await hass.async_block_till_done() await hass.async_block_till_done()
yield func 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

View File

@ -8,7 +8,7 @@ from homeassistant.components.google_assistant_sdk.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import CLIENT_ID from .conftest import CLIENT_ID, ComponentSetup
from tests.common import MockConfigEntry 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"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == "abort" assert result.get("type") == "abort"
assert result.get("reason") == "single_instance_allowed" 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"}

View File

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

View File

@ -1,7 +1,7 @@
"""Tests for Google Assistant SDK.""" """Tests for Google Assistant SDK."""
import http import http
import time import time
from unittest.mock import patch from unittest.mock import call, patch
import aiohttp import aiohttp
import pytest import pytest
@ -10,7 +10,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import ComponentSetup from .conftest import ComponentSetup, ExpectedCredentials
from tests.test_util.aiohttp import AiohttpClientMocker 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 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( async def test_send_text_command(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: ComponentSetup, setup_integration: ComponentSetup,
configured_language_code: str,
expected_language_code: str,
) -> None: ) -> None:
"""Test service call send_text_command calls TextAssistant.""" """Test service call send_text_command calls TextAssistant."""
await setup_integration() await setup_integration()
@ -107,18 +114,23 @@ async def test_send_text_command(
entries = hass.config_entries.async_entries(DOMAIN) entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED 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" command = "turn on home assistant unsupported device"
with patch( with patch(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" "homeassistant.components.google_assistant_sdk.helpers.TextAssistant"
) as mock_assist_call: ) as mock_text_assistant:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
"send_text_command", "send_text_command",
{"command": command}, {"command": command},
blocking=True, 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( @pytest.mark.parametrize(

View File

@ -3,9 +3,11 @@ from unittest.mock import call, patch
from homeassistant.components import notify from homeassistant.components import notify
from homeassistant.components.google_assistant_sdk import DOMAIN 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 homeassistant.core import HomeAssistant
from .conftest import ComponentSetup from .conftest import ComponentSetup, ExpectedCredentials
async def test_broadcast_no_targets( async def test_broadcast_no_targets(
@ -17,15 +19,16 @@ async def test_broadcast_no_targets(
message = "time for dinner" message = "time for dinner"
expected_command = "broadcast time for dinner" expected_command = "broadcast time for dinner"
with patch( with patch(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist" "homeassistant.components.google_assistant_sdk.helpers.TextAssistant"
) as mock_assist_call: ) as mock_text_assistant:
await hass.services.async_call( await hass.services.async_call(
notify.DOMAIN, notify.DOMAIN,
DOMAIN, DOMAIN,
{notify.ATTR_MESSAGE: message}, {notify.ATTR_MESSAGE: message},
) )
await hass.async_block_till_done() 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( async def test_broadcast_one_target(
@ -90,3 +93,39 @@ async def test_broadcast_empty_message(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_assist_call.assert_not_called() 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]