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 PRpull/86928/head
parent
ed8aa51c76
commit
d6158c0fcc
|
@ -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]
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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"
|
|
@ -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(
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue