Setup Google Cloud from the UI (#121502)

* Google Cloud can now be setup from the UI

* mypy

* Add BaseGoogleCloudProvider

* Allow clearing options in the UI

* Address feedback

* Don't translate Google Cloud title

* mypy

* Revert strict typing changes

* Address comments
This commit is contained in:
tronikos 2024-09-02 04:30:18 -07:00 committed by GitHub
parent f4a16c8dc9
commit d40e3145fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 696 additions and 38 deletions

View File

@ -549,7 +549,8 @@ build.json @home-assistant/supervisor
/tests/components/google_assistant/ @home-assistant/cloud /tests/components/google_assistant/ @home-assistant/cloud
/homeassistant/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_assistant_sdk/ @tronikos
/tests/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos
/homeassistant/components/google_cloud/ @lufton /homeassistant/components/google_cloud/ @lufton @tronikos
/tests/components/google_cloud/ @lufton @tronikos
/homeassistant/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos
/tests/components/google_generative_ai_conversation/ @tronikos /tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob /homeassistant/components/google_mail/ @tkdrob

View File

@ -1 +1,26 @@
"""The google_cloud component.""" """The google_cloud component."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.TTS]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,169 @@
"""Config flow for the Google Cloud integration."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any, cast
from google.cloud import texttospeech
import voluptuous as vol
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.tts import CONF_LANG
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import (
FileSelector,
FileSelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_KEY_FILE, CONF_SERVICE_ACCOUNT_INFO, DEFAULT_LANG, DOMAIN, TITLE
from .helpers import (
async_tts_voices,
tts_options_schema,
tts_platform_schema,
validate_service_account_info,
)
_LOGGER = logging.getLogger(__name__)
UPLOADED_KEY_FILE = "uploaded_key_file"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(UPLOADED_KEY_FILE): FileSelector(
FileSelectorConfig(accept=".json,application/json")
)
}
)
class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Cloud integration."""
VERSION = 1
_name: str | None = None
entry: ConfigEntry | None = None
abort_reason: str | None = None
def _parse_uploaded_file(self, uploaded_file_id: str) -> dict[str, Any]:
"""Read and parse an uploaded JSON file."""
with process_uploaded_file(self.hass, uploaded_file_id) as file_path:
contents = file_path.read_text()
return cast(dict[str, Any], json.loads(contents))
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, Any] = {}
if user_input is not None:
try:
service_account_info = await self.hass.async_add_executor_job(
self._parse_uploaded_file, user_input[UPLOADED_KEY_FILE]
)
validate_service_account_info(service_account_info)
except ValueError:
_LOGGER.exception("Reading uploaded JSON file failed")
errors["base"] = "invalid_file"
else:
data = {CONF_SERVICE_ACCOUNT_INFO: service_account_info}
if self.entry:
if TYPE_CHECKING:
assert self.abort_reason
return self.async_update_reload_and_abort(
self.entry, data=data, reason=self.abort_reason
)
return self.async_create_entry(title=TITLE, data=data)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"url": "https://console.cloud.google.com/apis/credentials/serviceaccountkey"
},
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import Google Cloud configuration from YAML."""
def _read_key_file() -> dict[str, Any]:
with open(
self.hass.config.path(import_data[CONF_KEY_FILE]), encoding="utf8"
) as f:
return cast(dict[str, Any], json.load(f))
service_account_info = await self.hass.async_add_executor_job(_read_key_file)
try:
validate_service_account_info(service_account_info)
except ValueError:
_LOGGER.exception("Reading credentials JSON file failed")
return self.async_abort(reason="invalid_file")
options = {
k: v for k, v in import_data.items() if k in tts_platform_schema().schema
}
options.pop(CONF_KEY_FILE)
_LOGGER.debug("Creating imported config entry with options: %s", options)
return self.async_create_entry(
title=TITLE,
data={CONF_SERVICE_ACCOUNT_INFO: service_account_info},
options=options,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> GoogleCloudOptionsFlowHandler:
"""Create the options flow."""
return GoogleCloudOptionsFlowHandler(config_entry)
class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Google Cloud options flow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
service_account_info = self.config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
client: texttospeech.TextToSpeechAsyncClient = (
texttospeech.TextToSpeechAsyncClient.from_service_account_info(
service_account_info
)
)
voices = await async_tts_voices(client)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Optional(
CONF_LANG,
default=DEFAULT_LANG,
): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN, options=list(voices)
)
),
**tts_options_schema(
self.options, voices, from_config_flow=True
).schema,
}
),
self.options,
),
)

View File

@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
DOMAIN = "google_cloud"
TITLE = "Google Cloud"
CONF_SERVICE_ACCOUNT_INFO = "service_account_info"
CONF_KEY_FILE = "key_file" CONF_KEY_FILE = "key_file"
DEFAULT_LANG = "en-US" DEFAULT_LANG = "en-US"

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import functools import functools
import operator import operator
from typing import Any from typing import Any
from google.cloud import texttospeech from google.cloud import texttospeech
from google.oauth2.service_account import Credentials
import voluptuous as vol import voluptuous as vol
from homeassistant.components.tts import CONF_LANG from homeassistant.components.tts import CONF_LANG
@ -52,14 +54,18 @@ async def async_tts_voices(
def tts_options_schema( def tts_options_schema(
config_options: dict[str, Any], config_options: dict[str, Any],
voices: dict[str, list[str]], voices: dict[str, list[str]],
from_config_flow: bool = False,
) -> vol.Schema: ) -> vol.Schema:
"""Return schema for TTS options with default values from config or constants.""" """Return schema for TTS options with default values from config or constants."""
# If we are called from the config flow we want the defaults to be from constants
# to allow clearing the current value (passed as suggested_value) in the UI.
# If we aren't called from the config flow we want the defaults to be from the config.
defaults = {} if from_config_flow else config_options
return vol.Schema( return vol.Schema(
{ {
vol.Optional( vol.Optional(
CONF_GENDER, CONF_GENDER,
description={"suggested_value": config_options.get(CONF_GENDER)}, default=defaults.get(
default=config_options.get(
CONF_GENDER, CONF_GENDER,
texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined] texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
), ),
@ -74,8 +80,7 @@ def tts_options_schema(
), ),
vol.Optional( vol.Optional(
CONF_VOICE, CONF_VOICE,
description={"suggested_value": config_options.get(CONF_VOICE)}, default=defaults.get(CONF_VOICE, DEFAULT_VOICE),
default=config_options.get(CONF_VOICE, DEFAULT_VOICE),
): SelectSelector( ): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
@ -84,8 +89,7 @@ def tts_options_schema(
), ),
vol.Optional( vol.Optional(
CONF_ENCODING, CONF_ENCODING,
description={"suggested_value": config_options.get(CONF_ENCODING)}, default=defaults.get(
default=config_options.get(
CONF_ENCODING, CONF_ENCODING,
texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined] texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
), ),
@ -100,23 +104,19 @@ def tts_options_schema(
), ),
vol.Optional( vol.Optional(
CONF_SPEED, CONF_SPEED,
description={"suggested_value": config_options.get(CONF_SPEED)}, default=defaults.get(CONF_SPEED, 1.0),
default=config_options.get(CONF_SPEED, 1.0),
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)), ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
vol.Optional( vol.Optional(
CONF_PITCH, CONF_PITCH,
description={"suggested_value": config_options.get(CONF_PITCH)}, default=defaults.get(CONF_PITCH, 0),
default=config_options.get(CONF_PITCH, 0),
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)), ): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
vol.Optional( vol.Optional(
CONF_GAIN, CONF_GAIN,
description={"suggested_value": config_options.get(CONF_GAIN)}, default=defaults.get(CONF_GAIN, 0),
default=config_options.get(CONF_GAIN, 0),
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)), ): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
vol.Optional( vol.Optional(
CONF_PROFILES, CONF_PROFILES,
description={"suggested_value": config_options.get(CONF_PROFILES)}, default=defaults.get(CONF_PROFILES, []),
default=config_options.get(CONF_PROFILES, []),
): SelectSelector( ): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
@ -137,8 +137,7 @@ def tts_options_schema(
), ),
vol.Optional( vol.Optional(
CONF_TEXT_TYPE, CONF_TEXT_TYPE,
description={"suggested_value": config_options.get(CONF_TEXT_TYPE)}, default=defaults.get(CONF_TEXT_TYPE, "text"),
default=config_options.get(CONF_TEXT_TYPE, "text"),
): vol.All( ): vol.All(
vol.Lower, vol.Lower,
SelectSelector( SelectSelector(
@ -166,3 +165,16 @@ def tts_platform_schema() -> vol.Schema:
), ),
} }
) )
def validate_service_account_info(info: Mapping[str, str]) -> None:
"""Validate service account info.
Args:
info: The service account info in Google format.
Raises:
ValueError: If the info is not in the expected format.
"""
Credentials.from_service_account_info(info) # type:ignore[no-untyped-call]

View File

@ -1,8 +1,11 @@
{ {
"domain": "google_cloud", "domain": "google_cloud",
"name": "Google Cloud Platform", "name": "Google Cloud",
"codeowners": ["@lufton"], "codeowners": ["@lufton", "@tronikos"],
"config_flow": true,
"dependencies": ["file_upload"],
"documentation": "https://www.home-assistant.io/integrations/google_cloud", "documentation": "https://www.home-assistant.io/integrations/google_cloud",
"integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"requirements": ["google-cloud-texttospeech==2.17.2"] "requirements": ["google-cloud-texttospeech==2.17.2"]
} }

View File

@ -0,0 +1,32 @@
{
"config": {
"step": {
"user": {
"description": "Upload your Google Cloud service account JSON file that you can create at {url}.",
"data": {
"uploaded_key_file": "Upload service account JSON file"
}
}
},
"error": {
"invalid_file": "Invalid service account JSON file"
}
},
"options": {
"step": {
"init": {
"data": {
"language": "Default language of the voice",
"gender": "Default gender of the voice",
"voice": "Default voice name (overrides language and gender)",
"encoding": "Default audio encoder",
"speed": "Default rate/speed of the voice",
"pitch": "Default pitch of the voice",
"gain": "Default volume gain (in dB) of the voice",
"profiles": "Default audio profiles",
"text_type": "Default text type"
}
}
}
}
}

View File

@ -1,10 +1,12 @@
"""Support for the Google Cloud TTS service.""" """Support for the Google Cloud TTS service."""
from __future__ import annotations
import logging import logging
import os from pathlib import Path
from typing import Any, cast from typing import Any, cast
from google.api_core.exceptions import GoogleAPIError from google.api_core.exceptions import GoogleAPIError, Unauthenticated
from google.cloud import texttospeech from google.cloud import texttospeech
import voluptuous as vol import voluptuous as vol
@ -12,10 +14,14 @@ from homeassistant.components.tts import (
CONF_LANG, CONF_LANG,
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
Provider, Provider,
TextToSpeechEntity,
TtsAudioType, TtsAudioType,
Voice, Voice,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import ( from .const import (
@ -25,10 +31,12 @@ from .const import (
CONF_KEY_FILE, CONF_KEY_FILE,
CONF_PITCH, CONF_PITCH,
CONF_PROFILES, CONF_PROFILES,
CONF_SERVICE_ACCOUNT_INFO,
CONF_SPEED, CONF_SPEED,
CONF_TEXT_TYPE, CONF_TEXT_TYPE,
CONF_VOICE, CONF_VOICE,
DEFAULT_LANG, DEFAULT_LANG,
DOMAIN,
) )
from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema
@ -45,13 +53,20 @@ async def async_get_engine(
"""Set up Google Cloud TTS component.""" """Set up Google Cloud TTS component."""
if key_file := config.get(CONF_KEY_FILE): if key_file := config.get(CONF_KEY_FILE):
key_file = hass.config.path(key_file) key_file = hass.config.path(key_file)
if not os.path.isfile(key_file): if not Path(key_file).is_file():
_LOGGER.error("File %s doesn't exist", key_file) _LOGGER.error("File %s doesn't exist", key_file)
return None return None
if key_file: if key_file:
client = texttospeech.TextToSpeechAsyncClient.from_service_account_file( client = texttospeech.TextToSpeechAsyncClient.from_service_account_file(
key_file key_file
) )
if not hass.config_entries.async_entries(DOMAIN):
_LOGGER.debug("Creating config entry by importing: %s", config)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
else: else:
client = texttospeech.TextToSpeechAsyncClient() client = texttospeech.TextToSpeechAsyncClient()
try: try:
@ -60,7 +75,6 @@ async def async_get_engine(
_LOGGER.error("Error from calling list_voices: %s", err) _LOGGER.error("Error from calling list_voices: %s", err)
return None return None
return GoogleCloudTTSProvider( return GoogleCloudTTSProvider(
hass,
client, client,
voices, voices,
config.get(CONF_LANG, DEFAULT_LANG), config.get(CONF_LANG, DEFAULT_LANG),
@ -68,20 +82,51 @@ async def async_get_engine(
) )
class GoogleCloudTTSProvider(Provider): async def async_setup_entry(
"""The Google Cloud TTS API provider.""" hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Google Cloud text-to-speech."""
service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
client: texttospeech.TextToSpeechAsyncClient = (
texttospeech.TextToSpeechAsyncClient.from_service_account_info(
service_account_info
)
)
try:
voices = await async_tts_voices(client)
except GoogleAPIError as err:
_LOGGER.error("Error from calling list_voices: %s", err)
if isinstance(err, Unauthenticated):
config_entry.async_start_reauth(hass)
return
options_schema = tts_options_schema(dict(config_entry.options), voices)
language = config_entry.options.get(CONF_LANG, DEFAULT_LANG)
async_add_entities(
[
GoogleCloudTTSEntity(
config_entry,
client,
voices,
language,
options_schema,
)
]
)
class BaseGoogleCloudProvider:
"""The Google Cloud TTS base provider."""
def __init__( def __init__(
self, self,
hass: HomeAssistant,
client: texttospeech.TextToSpeechAsyncClient, client: texttospeech.TextToSpeechAsyncClient,
voices: dict[str, list[str]], voices: dict[str, list[str]],
language: str, language: str,
options_schema: vol.Schema, options_schema: vol.Schema,
) -> None: ) -> None:
"""Init Google Cloud TTS service.""" """Init Google Cloud TTS base provider."""
self.hass = hass
self.name = "Google Cloud TTS"
self._client = client self._client = client
self._voices = voices self._voices = voices
self._language = language self._language = language
@ -114,7 +159,7 @@ class GoogleCloudTTSProvider(Provider):
return None return None
return [Voice(voice, voice) for voice in voices] return [Voice(voice, voice) for voice in voices]
async def async_get_tts_audio( async def _async_get_tts_audio(
self, self,
message: str, message: str,
language: str, language: str,
@ -155,11 +200,7 @@ class GoogleCloudTTSProvider(Provider):
), ),
) )
try:
response = await self._client.synthesize_speech(request, timeout=10) response = await self._client.synthesize_speech(request, timeout=10)
except GoogleAPIError as err:
_LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
return None, None
if encoding == texttospeech.AudioEncoding.MP3: if encoding == texttospeech.AudioEncoding.MP3:
extension = "mp3" extension = "mp3"
@ -169,3 +210,64 @@ class GoogleCloudTTSProvider(Provider):
extension = "wav" extension = "wav"
return extension, response.audio_content return extension, response.audio_content
class GoogleCloudTTSEntity(BaseGoogleCloudProvider, TextToSpeechEntity):
"""The Google Cloud TTS entity."""
def __init__(
self,
entry: ConfigEntry,
client: texttospeech.TextToSpeechAsyncClient,
voices: dict[str, list[str]],
language: str,
options_schema: vol.Schema,
) -> None:
"""Init Google Cloud TTS entity."""
super().__init__(client, voices, language, options_schema)
self._attr_unique_id = f"{entry.entry_id}-tts"
self._attr_name = entry.title
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Google",
model="Cloud",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._entry = entry
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load TTS from Google Cloud."""
try:
return await self._async_get_tts_audio(message, language, options)
except GoogleAPIError as err:
_LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
if isinstance(err, Unauthenticated):
self._entry.async_start_reauth(self.hass)
return None, None
class GoogleCloudTTSProvider(BaseGoogleCloudProvider, Provider):
"""The Google Cloud TTS API provider."""
def __init__(
self,
client: texttospeech.TextToSpeechAsyncClient,
voices: dict[str, list[str]],
language: str,
options_schema: vol.Schema,
) -> None:
"""Init Google Cloud TTS service."""
super().__init__(client, voices, language, options_schema)
self.name = "Google Cloud TTS"
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load TTS from Google Cloud."""
try:
return await self._async_get_tts_audio(message, language, options)
except GoogleAPIError as err:
_LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
return None, None

View File

@ -222,6 +222,7 @@ FLOWS = {
"goodwe", "goodwe",
"google", "google",
"google_assistant_sdk", "google_assistant_sdk",
"google_cloud",
"google_generative_ai_conversation", "google_generative_ai_conversation",
"google_mail", "google_mail",
"google_photos", "google_photos",

View File

@ -2251,10 +2251,10 @@
"name": "Google Assistant SDK" "name": "Google Assistant SDK"
}, },
"google_cloud": { "google_cloud": {
"integration_type": "hub", "integration_type": "service",
"config_flow": false, "config_flow": true,
"iot_class": "cloud_push", "iot_class": "cloud_push",
"name": "Google Cloud Platform" "name": "Google Cloud"
}, },
"google_domains": { "google_domains": {
"integration_type": "hub", "integration_type": "hub",

View File

@ -836,6 +836,9 @@ google-api-python-client==2.71.0
# homeassistant.components.google_pubsub # homeassistant.components.google_pubsub
google-cloud-pubsub==2.23.0 google-cloud-pubsub==2.23.0
# homeassistant.components.google_cloud
google-cloud-texttospeech==2.17.2
# homeassistant.components.google_generative_ai_conversation # homeassistant.components.google_generative_ai_conversation
google-generativeai==0.7.2 google-generativeai==0.7.2

View File

@ -0,0 +1 @@
"""Tests for the Google Cloud integration."""

View File

@ -0,0 +1,122 @@
"""Tests helpers."""
from collections.abc import Generator
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from google.cloud.texttospeech_v1.types import cloud_tts
import pytest
from homeassistant.components.google_cloud.const import (
CONF_SERVICE_ACCOUNT_INFO,
DOMAIN,
)
from tests.common import MockConfigEntry
VALID_SERVICE_ACCOUNT_INFO = {
"type": "service_account",
"project_id": "my project id",
"private_key_id": "my private key if",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n",
"client_email": "my client email",
"client_id": "my client id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/service-account",
"universe_domain": "googleapis.com",
}
@pytest.fixture
def create_google_credentials_json(tmp_path: Path) -> str:
"""Create googlecredentials.json."""
file_path = tmp_path / "googlecredentials.json"
with open(file_path, "w", encoding="utf8") as f:
json.dump(VALID_SERVICE_ACCOUNT_INFO, f)
return str(file_path)
@pytest.fixture
def create_invalid_google_credentials_json(create_google_credentials_json: str) -> str:
"""Create invalid googlecredentials.json."""
invalid_service_account_info = VALID_SERVICE_ACCOUNT_INFO.copy()
invalid_service_account_info.pop("client_email")
with open(create_google_credentials_json, "w", encoding="utf8") as f:
json.dump(invalid_service_account_info, f)
return create_google_credentials_json
@pytest.fixture
def mock_process_uploaded_file(
create_google_credentials_json: str,
) -> Generator[MagicMock]:
"""Mock upload certificate files."""
with patch(
"homeassistant.components.google_cloud.config_flow.process_uploaded_file",
return_value=Path(create_google_credentials_json),
) as mock_upload:
yield mock_upload
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="my Google Cloud title",
domain=DOMAIN,
data={CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO},
)
@pytest.fixture
def mock_api_tts() -> AsyncMock:
"""Return a mocked TTS client."""
mock_client = AsyncMock()
mock_client.list_voices.return_value = cloud_tts.ListVoicesResponse(
voices=[
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"),
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"),
cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"),
]
)
return mock_client
@pytest.fixture
def mock_api_tts_from_service_account_info(
mock_api_tts: AsyncMock,
) -> Generator[AsyncMock]:
"""Return a mocked TTS client created with from_service_account_info."""
with (
patch(
"google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_info",
return_value=mock_api_tts,
),
):
yield mock_api_tts
@pytest.fixture
def mock_api_tts_from_service_account_file(
mock_api_tts: AsyncMock,
) -> Generator[AsyncMock]:
"""Return a mocked TTS client created with from_service_account_file."""
with (
patch(
"google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_file",
return_value=mock_api_tts,
),
):
yield mock_api_tts
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.google_cloud.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,183 @@
"""Test the Google Cloud config flow."""
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from homeassistant import config_entries
from homeassistant.components import tts
from homeassistant.components.google_cloud.config_flow import UPLOADED_KEY_FILE
from homeassistant.components.google_cloud.const import (
CONF_KEY_FILE,
CONF_SERVICE_ACCOUNT_INFO,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from .conftest import VALID_SERVICE_ACCOUNT_INFO
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user flow creates entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
uploaded_file = str(uuid4())
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{UPLOADED_KEY_FILE: uploaded_file},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Google Cloud"
assert result["data"] == {CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO}
mock_process_uploaded_file.assert_called_with(hass, uploaded_file)
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_missing_file(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user flow when uploaded file is missing."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{UPLOADED_KEY_FILE: str(uuid4())},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_file"}
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_flow_invalid_file(
hass: HomeAssistant,
create_invalid_google_credentials_json: str,
mock_process_uploaded_file: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user flow when uploaded file is invalid."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
uploaded_file = str(uuid4())
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{UPLOADED_KEY_FILE: uploaded_file},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_file"}
mock_process_uploaded_file.assert_called_with(hass, uploaded_file)
assert len(mock_setup_entry.mock_calls) == 0
async def test_import_flow(
hass: HomeAssistant,
create_google_credentials_json: str,
mock_api_tts_from_service_account_file: AsyncMock,
mock_api_tts_from_service_account_info: AsyncMock,
) -> None:
"""Test the import flow."""
assert not hass.config_entries.async_entries(DOMAIN)
assert await async_setup_component(
hass,
tts.DOMAIN,
{
tts.DOMAIN: {CONF_PLATFORM: DOMAIN}
| {CONF_KEY_FILE: create_google_credentials_json}
},
)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.state is config_entries.ConfigEntryState.LOADED
async def test_import_flow_invalid_file(
hass: HomeAssistant,
create_invalid_google_credentials_json: str,
mock_api_tts_from_service_account_file: AsyncMock,
) -> None:
"""Test the import flow when the key file is invalid."""
assert not hass.config_entries.async_entries(DOMAIN)
assert await async_setup_component(
hass,
tts.DOMAIN,
{
tts.DOMAIN: {CONF_PLATFORM: DOMAIN}
| {CONF_KEY_FILE: create_invalid_google_credentials_json}
},
)
await hass.async_block_till_done()
assert not hass.config_entries.async_entries(DOMAIN)
assert mock_api_tts_from_service_account_file.list_voices.call_count == 1
async def test_options_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_tts_from_service_account_info: AsyncMock,
) -> None:
"""Test options flow."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_api_tts_from_service_account_info.list_voices.call_count == 1
assert mock_config_entry.options == {}
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
data_schema = result["data_schema"].schema
assert set(data_schema) == {
"language",
"gender",
"voice",
"encoding",
"speed",
"pitch",
"gain",
"profiles",
"text_type",
}
assert mock_api_tts_from_service_account_info.list_voices.call_count == 2
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"language": "el-GR"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {
"language": "el-GR",
"gender": "NEUTRAL",
"voice": "",
"encoding": "MP3",
"speed": 1.0,
"pitch": 0.0,
"gain": 0.0,
"profiles": [],
"text_type": "text",
}
assert mock_api_tts_from_service_account_info.list_voices.call_count == 3