Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
34 changes: 28 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,36 @@
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_MEM_STORAGE,
DATA_SESSION,
DOMAIN,
)
from .helpers import (
GoogleAssistantSDKAudioView,
InMemoryStorage,
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 +58,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 +72,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

mem_storage = InMemoryStorage(hass)
hass.data[DOMAIN][entry.entry_id][DATA_MEM_STORAGE] = mem_storage
hass.http.register_view(GoogleAssistantSDKAudioView(mem_storage))

await async_setup_service(hass)

Expand Down Expand Up @@ -88,7 +107,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 +158,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_MEM_STORAGE: Final = "mem_storage"
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"
106 changes: 100 additions & 6 deletions homeassistant/components/google_assistant_sdk/helpers.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
"""Helper classes for Google Assistant SDK integration."""
from __future__ import annotations

from http import HTTPStatus
import logging
from typing import Any
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 homeassistant.helpers.event import async_call_later

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

_LOGGER = logging.getLogger(__name__)

Expand All @@ -28,12 +48,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 +65,32 @@ 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:
mem_storage: InMemoryStorage = hass.data[DOMAIN][entry.entry_id][
DATA_MEM_STORAGE
]
audio_url = GoogleAssistantSDKAudioView.url.format(
filename=mem_storage.store_and_get_identifier(audio_response)
)
await hass.services.async_call(
DOMAIN_MP,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: media_players,
ATTR_MEDIA_CONTENT_ID: audio_url,
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
)


def default_language_code(hass: HomeAssistant):
Expand All @@ -55,3 +99,53 @@ 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 InMemoryStorage:
"""Temporarily store and retrieve data from in memory storage."""

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

def store_and_get_identifier(self, data: bytes) -> str:
"""
Temporarily store data and return identifier to be able to retrieve it.

Data expires after 5 minutes.
"""
identifier: str = uuid.uuid1().hex
self.mem[identifier] = data

def async_remove_from_mem(*_: Any) -> None:
"""Cleanup memory."""
self.mem.pop(identifier, None)

# Remove the entry from memory 5 minutes later
async_call_later(self.hass, 5 * 60, async_remove_from_mem)

return identifier

def retrieve(self, identifier: str) -> bytes | None:
"""Retrieve previously stored data."""
return self.mem.get(identifier)


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

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

def __init__(self, mem_storage: InMemoryStorage) -> None:
"""Initialize GoogleAssistantSDKView."""
self.mem_storage: InMemoryStorage = mem_storage

async def get(self, request: web.Request, filename: str) -> web.Response:
"""Start a get request."""
audio = self.mem_storage.retrieve(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
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
Loading