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
pull/86928/head
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:
- 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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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."""
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(

View File

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