Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions homeassistant/components/google_assistant_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,35 @@
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import discovery, intent
from homeassistant.helpers import config_validation as cv, discovery, intent
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType

from .const import CONF_ENABLE_CONVERSATION_AGENT, CONF_LANGUAGE_CODE, DOMAIN
from .helpers import async_send_text_commands, default_language_code
from .const import (
CONF_ENABLE_CONVERSATION_AGENT,
CONF_LANGUAGE_CODE,
DATA_AUDIO_VIEW,
DATA_SESSION,
DOMAIN,
)
from .helpers import (
GoogleAssistantSDKAudioView,
async_send_text_commands,
default_language_code,
)

SERVICE_SEND_TEXT_COMMAND = "send_text_command"
SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND = "command"
SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER = "media_player"
SERVICE_SEND_TEXT_COMMAND_SCHEMA = vol.All(
{
vol.Required(SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND): vol.All(
str, vol.Length(min=1)
),
vol.Optional(SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER): cv.comp_entity_ids,
},
)

Expand All @@ -45,6 +57,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Assistant SDK from a config entry."""
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {}

implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
try:
Expand All @@ -57,7 +71,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session
hass.data[DOMAIN][entry.entry_id][DATA_SESSION] = session

audio_view = GoogleAssistantSDKAudioView(hass)
hass.http.register_view(audio_view)
hass.data[DOMAIN][entry.entry_id][DATA_AUDIO_VIEW] = audio_view

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more commonly seen pattern is to use something like an audio storage manager, rather than passing around references to the View directly. Other code can stick stuff in the manger and the view can interact with that.

(You could also consider having that intermediate manager thing be single object that holds the session, though not sure)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


await async_setup_service(hass)

Expand Down Expand Up @@ -88,7 +106,10 @@ async def async_setup_service(hass: HomeAssistant) -> None:
async def send_text_command(call: ServiceCall) -> None:
"""Send a text command to Google Assistant SDK."""
command: str = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND]
await async_send_text_commands([command], hass)
media_players: list[str] | None = call.data.get(
SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER
)
await async_send_text_commands(hass, [command], media_players)

hass.services.async_register(
DOMAIN,
Expand Down Expand Up @@ -136,7 +157,7 @@ async def async_process(
if self.session:
session = self.session
else:
session = self.hass.data[DOMAIN].get(self.entry.entry_id)
session = self.hass.data[DOMAIN][self.entry.entry_id][DATA_SESSION]
self.session = session
if not session.valid_token:
await session.async_ensure_token_valid()
Expand Down
6 changes: 4 additions & 2 deletions homeassistant/components/google_assistant_sdk/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@

DEFAULT_NAME: Final = "Google Assistant SDK"

CONF_ENABLE_CONVERSATION_AGENT: Final = "enable_conversation_agent"
CONF_LANGUAGE_CODE: Final = "language_code"

DATA_AUDIO_VIEW: Final = "audio_view"
DATA_SESSION: Final = "session"

# https://developers.google.com/assistant/sdk/reference/rpc/languages
SUPPORTED_LANGUAGE_CODES: Final = [
"de-DE",
Expand All @@ -24,5 +28,3 @@
"ko-KR",
"pt-BR",
]

CONF_ENABLE_CONVERSATION_AGENT: Final = "enable_conversation_agent"
87 changes: 81 additions & 6 deletions homeassistant/components/google_assistant_sdk/helpers.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
"""Helper classes for Google Assistant SDK integration."""
from __future__ import annotations

from http import HTTPStatus
import logging
import uuid

import aiohttp
from aiohttp import web
from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials

from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
DOMAIN as DOMAIN_MP,
SERVICE_PLAY_MEDIA,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session

from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES
from .const import (
CONF_LANGUAGE_CODE,
DATA_AUDIO_VIEW,
DATA_SESSION,
DOMAIN,
SUPPORTED_LANGUAGE_CODES,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -28,12 +46,14 @@
}


async def async_send_text_commands(commands: list[str], hass: HomeAssistant) -> None:
async def async_send_text_commands(
hass: HomeAssistant, commands: list[str], media_players: list[str] | None = None
) -> None:
"""Send text commands to Google Assistant Service."""
# There can only be 1 entry (config_flow has single_instance_allowed)
entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]

session: OAuth2Session = hass.data[DOMAIN].get(entry.entry_id)
session: OAuth2Session = hass.data[DOMAIN][entry.entry_id][DATA_SESSION]
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
Expand All @@ -43,10 +63,31 @@ async def async_send_text_commands(commands: list[str], hass: HomeAssistant) ->

credentials = Credentials(session.token[CONF_ACCESS_TOKEN])
language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass))
with TextAssistant(credentials, language_code) as assistant:
with TextAssistant(
credentials, language_code, audio_out=bool(media_players)
) as assistant:
for command in commands:
text_response = assistant.assist(command)[0]
resp = assistant.assist(command)
text_response = resp[0]
_LOGGER.debug("command: %s\nresponse: %s", command, text_response)
audio_response = resp[2]
if media_players and audio_response:
audio_view: GoogleAssistantSDKAudioView = hass.data[DOMAIN][
entry.entry_id
][DATA_AUDIO_VIEW]
await hass.services.async_call(
DOMAIN_MP,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: media_players,
ATTR_MEDIA_CONTENT_ID: audio_view.memcache_store_and_get_url(
audio_response
),
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
)


def default_language_code(hass: HomeAssistant):
Expand All @@ -55,3 +96,37 @@ def default_language_code(hass: HomeAssistant):
if language_code in SUPPORTED_LANGUAGE_CODES:
return language_code
return DEFAULT_LANGUAGE_CODES.get(hass.config.language, "en-US")


class GoogleAssistantSDKAudioView(HomeAssistantView):
"""Google Assistant SDK view to serve audio responses."""

requires_auth = False
url = "/api/google_assistant_sdk/audio/{filename}"
name = "api:google_assistant_sdk:audio"

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize GoogleAssistantSDKView."""
self.hass: HomeAssistant = hass
self.mem_cache: dict[str, bytes] = {}

def memcache_store_and_get_url(self, audio: bytes) -> str:
"""Write audio to memcache and return URL to serve it."""
filename: str = uuid.uuid1().hex
self.mem_cache[filename] = audio

def async_remove_from_mem() -> None:
"""Cleanup memcache."""
self.mem_cache.pop(filename, None)

# Remove the entry from memcache 5 minutes later
self.hass.loop.call_later(5 * 60, async_remove_from_mem)

return self.url.format(filename=filename)

async def get(self, request: web.Request, filename: str) -> web.Response:
"""Start a get request."""
audio = self.mem_cache.get(filename)
if not audio:
return web.Response(status=HTTPStatus.NOT_FOUND)
return web.Response(body=audio, content_type="audio/mpeg")
4 changes: 2 additions & 2 deletions homeassistant/components/google_assistant_sdk/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"domain": "google_assistant_sdk",
"name": "Google Assistant SDK",
"config_flow": true,
"dependencies": ["application_credentials"],
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/",
"requirements": ["gassist-text==0.0.7"],
"requirements": ["gassist-text==0.0.8"],
"codeowners": ["@tronikos"],
"iot_class": "cloud_polling",
"integration_type": "service"
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/google_assistant_sdk/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
commands.append(
broadcast_commands(language_code)[1].format(message, target)
)
await async_send_text_commands(commands, self.hass)
await async_send_text_commands(self.hass, commands)
7 changes: 7 additions & 0 deletions homeassistant/components/google_assistant_sdk/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ send_text_command:
example: turn off kitchen TV
selector:
text:
media_player:
name: Media Player Entity
description: Name(s) of media player entities to play response on
example: media_player.living_room_speaker
selector:
entity:
domain: media_player
5 changes: 4 additions & 1 deletion homeassistant/components/media_player/browse_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType

# Paths that we don't need to sign
PATHS_WITHOUT_AUTH = ("/api/tts_proxy/",)
PATHS_WITHOUT_AUTH = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While there is not a super high security bar here, uuid1 paths seem below the bar as my impression is those are on the easier side to predict. Is there a technical motivation for opting out of signed paths?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just followed what tts_proxy does.

See comment below where this constant is used:

    elif parsed.path.startswith(PATHS_WITHOUT_AUTH):
        # We don't sign this path if it doesn't need auth. Although signing itself can't
        # hurt, some devices are unable to handle long URLs and the auth signature might
        # push it over.
        pass

I just did some tests on my setup and I don't have any such issues on any of my media players. So I removed the change in this file and also set requires_auth = True in GoogleAssistantSDKAudioView.

If anyone complains that playback doesn't work because of long URLs we can consider adding this back.

"/api/google_assistant_sdk/audio/",
"/api/tts_proxy/",
)


@callback
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ fritzconnection==1.10.3
gTTS==2.2.4

# homeassistant.components.google_assistant_sdk
gassist-text==0.0.7
gassist-text==0.0.8

# homeassistant.components.google
gcal-sync==4.1.2
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ fritzconnection==1.10.3
gTTS==2.2.4

# homeassistant.components.google_assistant_sdk
gassist-text==0.0.7
gassist-text==0.0.8

# homeassistant.components.google
gcal-sync==4.1.2
Expand Down
83 changes: 82 additions & 1 deletion tests/components/google_assistant_sdk/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@

from .conftest import ComponentSetup, ExpectedCredentials

from tests.common import async_mock_service
from tests.test_util.aiohttp import AiohttpClientMocker


async def fetch_api_url(hass_client, url):
"""Fetch an API URL and return HTTP status and contents."""
client = await hass_client()
response = await client.get(url)
contents = await response.read()
return response.status, contents


async def test_setup_success(
hass: HomeAssistant, setup_integration: ComponentSetup
) -> None:
Expand Down Expand Up @@ -129,7 +138,7 @@ async def test_send_text_command(
blocking=True,
)
mock_text_assistant.assert_called_once_with(
ExpectedCredentials(), expected_language_code
ExpectedCredentials(), expected_language_code, audio_out=False
)
mock_text_assistant.assert_has_calls([call().__enter__().assist(command)])

Expand Down Expand Up @@ -180,6 +189,78 @@ async def test_send_text_command_expired_token_refresh_failure(
assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth


async def test_send_text_command_media_player(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expiration also seems like an important thing to test. Typically this is done with async_fire_time_changed . Speculation:... though you may need to use async_call_later or one of the home assistant variants to make this happen, rather than calling call later on the loop directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.
I copied this call later on the loop directly from tts, where it's untested. I wanted to test it but I couldn't figure out how. Thanks for pointing me to the right functions!

hass: HomeAssistant, setup_integration: ComponentSetup, hass_client
) -> None:
"""Test send_text_command with media_player."""
await setup_integration()

play_media_calls = async_mock_service(hass, "media_player", "play_media")

command = "tell me a joke"
media_player = "media_player.office_speaker"
audio_response1 = b"joke1 audio response bytes"
audio_response2 = b"joke2 audio response bytes"
with patch(
"homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist",
side_effect=[
("joke1 text", None, audio_response1),
("joke2 text", None, audio_response2),
],
) as mock_assist_call:
# Run the same command twice, getting different audio response each time.
await hass.services.async_call(
DOMAIN,
"send_text_command",
{
"command": command,
"media_player": media_player,
},
blocking=True,
)
await hass.services.async_call(
DOMAIN,
"send_text_command",
{
"command": command,
"media_player": media_player,
},
blocking=True,
)

mock_assist_call.assert_has_calls([call(command), call(command)])
assert len(play_media_calls) == 2
for play_media_call in play_media_calls:
assert play_media_call.data["entity_id"] == [media_player]
assert play_media_call.data["media_content_id"].startswith(
"/api/google_assistant_sdk/audio/"
)

audio_url1 = play_media_calls[0].data["media_content_id"]
audio_url2 = play_media_calls[1].data["media_content_id"]
assert audio_url1 != audio_url2

# Assert that both audio responses can be served multiple times.
status, response = await fetch_api_url(hass_client, audio_url1)
assert status == http.HTTPStatus.OK
assert response == audio_response1
status, response = await fetch_api_url(hass_client, audio_url1)
assert status == http.HTTPStatus.OK
assert response == audio_response1
status, response = await fetch_api_url(hass_client, audio_url2)
assert status == http.HTTPStatus.OK
assert response == audio_response2
status, response = await fetch_api_url(hass_client, audio_url2)
assert status == http.HTTPStatus.OK
assert response == audio_response2

# Assert a nonexistent URL returns 404
status, _ = await fetch_api_url(
hass_client, "/api/google_assistant_sdk/audio/nonexistent"
)
assert status == http.HTTPStatus.NOT_FOUND


async def test_conversation_agent(
hass: HomeAssistant,
setup_integration: ComponentSetup,
Expand Down
Loading