Google Assistant Local SDK (#27428)

* Local Google

* Fix test

* Fix tests
This commit is contained in:
Paulus Schoutsen 2019-10-13 14:16:27 -07:00 committed by GitHub
parent 3454b6fa87
commit e866d769e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 512 additions and 52 deletions

View file

@ -110,12 +110,15 @@ class CloudClient(Interface):
if not self.cloud.is_logged_in: if not self.cloud.is_logged_in:
return return
if self.alexa_config.should_report_state: if self.alexa_config.enabled and self.alexa_config.should_report_state:
try: try:
await self.alexa_config.async_enable_proactive_mode() await self.alexa_config.async_enable_proactive_mode()
except alexa_errors.NoTokenAvailable: except alexa_errors.NoTokenAvailable:
pass pass
if self.google_config.enabled:
self.google_config.async_enable_local_sdk()
if self.google_config.should_report_state: if self.google_config.should_report_state:
self.google_config.async_enable_report_state() self.google_config.async_enable_report_state()

View file

@ -16,6 +16,7 @@ PREF_OVERRIDE_NAME = "override_name"
PREF_DISABLE_2FA = "disable_2fa" PREF_DISABLE_2FA = "disable_2fa"
PREF_ALIASES = "aliases" PREF_ALIASES = "aliases"
PREF_SHOULD_EXPOSE = "should_expose" PREF_SHOULD_EXPOSE = "should_expose"
PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id"
DEFAULT_SHOULD_EXPOSE = True DEFAULT_SHOULD_EXPOSE = True
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = False DEFAULT_ALEXA_REPORT_STATE = False

View file

@ -63,6 +63,19 @@ class CloudGoogleConfig(AbstractConfig):
"""Return if states should be proactively reported.""" """Return if states should be proactively reported."""
return self._prefs.google_report_state return self._prefs.google_report_state
@property
def local_sdk_webhook_id(self):
"""Return the local SDK webhook.
Return None to disable the local SDK.
"""
return self._prefs.google_local_webhook_id
@property
def local_sdk_user_id(self):
"""Return the user ID to be used for actions received via the local SDK."""
return self._prefs.cloud_user
def should_expose(self, state): def should_expose(self, state):
"""If a state object should be exposed.""" """If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id) return self._should_expose_entity_id(state.entity_id)
@ -131,18 +144,20 @@ class CloudGoogleConfig(AbstractConfig):
# State reporting is reported as a property on entities. # State reporting is reported as a property on entities.
# So when we change it, we need to sync all entities. # So when we change it, we need to sync all entities.
await self.async_sync_entities() await self.async_sync_entities()
return
# If entity prefs are the same or we have filter in config.yaml, # If entity prefs are the same or we have filter in config.yaml,
# don't sync. # don't sync.
if ( elif (
self._cur_entity_prefs is prefs.google_entity_configs self._cur_entity_prefs is not prefs.google_entity_configs
or not self._config["filter"].empty_filter and self._config["filter"].empty_filter
): ):
return
self.async_schedule_google_sync() self.async_schedule_google_sync()
if self.enabled and not self.is_local_sdk_active:
self.async_enable_local_sdk()
elif not self.enabled and self.is_local_sdk_active:
self.async_disable_local_sdk()
async def _handle_entity_registry_updated(self, event): async def _handle_entity_registry_updated(self, event):
"""Handle when entity registry updated.""" """Handle when entity registry updated."""
if not self.enabled or not self._cloud.is_logged_in: if not self.enabled or not self._cloud.is_logged_in:

View file

@ -21,6 +21,7 @@ from .const import (
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
DEFAULT_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE,
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_LOCAL_WEBHOOK_ID,
DEFAULT_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE,
InvalidTrustedNetworks, InvalidTrustedNetworks,
InvalidTrustedProxies, InvalidTrustedProxies,
@ -59,6 +60,14 @@ class CloudPreferences:
self._prefs = prefs self._prefs = prefs
if PREF_GOOGLE_LOCAL_WEBHOOK_ID not in self._prefs:
await self._save_prefs(
{
**self._prefs,
PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(),
}
)
@callback @callback
def async_listen_updates(self, listener): def async_listen_updates(self, listener):
"""Listen for updates to the preferences.""" """Listen for updates to the preferences."""
@ -79,6 +88,8 @@ class CloudPreferences:
google_report_state=_UNDEF, google_report_state=_UNDEF,
): ):
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs}
for key, value in ( for key, value in (
(PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_GOOGLE, google_enabled),
(PREF_ENABLE_ALEXA, alexa_enabled), (PREF_ENABLE_ALEXA, alexa_enabled),
@ -92,20 +103,17 @@ class CloudPreferences:
(PREF_GOOGLE_REPORT_STATE, google_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state),
): ):
if value is not _UNDEF: if value is not _UNDEF:
self._prefs[key] = value prefs[key] = value
if remote_enabled is True and self._has_local_trusted_network: if remote_enabled is True and self._has_local_trusted_network:
self._prefs[PREF_ENABLE_REMOTE] = False prefs[PREF_ENABLE_REMOTE] = False
raise InvalidTrustedNetworks raise InvalidTrustedNetworks
if remote_enabled is True and self._has_local_trusted_proxies: if remote_enabled is True and self._has_local_trusted_proxies:
self._prefs[PREF_ENABLE_REMOTE] = False prefs[PREF_ENABLE_REMOTE] = False
raise InvalidTrustedProxies raise InvalidTrustedProxies
await self._store.async_save(self._prefs) await self._save_prefs(prefs)
for listener in self._listeners:
self._hass.async_create_task(async_create_catching_coro(listener(self)))
async def async_update_google_entity_config( async def async_update_google_entity_config(
self, self,
@ -216,6 +224,11 @@ class CloudPreferences:
"""Return Google Entity configurations.""" """Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property
def google_local_webhook_id(self):
"""Return Google webhook ID to receive local messages."""
return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID]
@property @property
def alexa_entity_configs(self): def alexa_entity_configs(self):
"""Return Alexa Entity configurations.""" """Return Alexa Entity configurations."""
@ -262,3 +275,11 @@ class CloudPreferences:
return True return True
return False return False
async def _save_prefs(self, prefs):
"""Save preferences to disk."""
self._prefs = prefs
await self._store.async_save(self._prefs)
for listener in self._listeners:
self._hass.async_create_task(async_create_catching_coro(listener(self)))

View file

@ -1,10 +1,15 @@
"""Helper classes for Google Assistant integration.""" """Helper classes for Google Assistant integration."""
from asyncio import gather from asyncio import gather
from collections.abc import Mapping from collections.abc import Mapping
from typing import List import logging
import pprint
from typing import List, Optional
from aiohttp.web import json_response
from homeassistant.core import Context, callback, HomeAssistant, State from homeassistant.core import Context, callback, HomeAssistant, State
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.components import webhook
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_NAME,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
@ -15,6 +20,7 @@ from homeassistant.const import (
from . import trait from . import trait
from .const import ( from .const import (
DOMAIN,
DOMAIN_TO_GOOGLE_TYPES, DOMAIN_TO_GOOGLE_TYPES,
CONF_ALIASES, CONF_ALIASES,
ERR_FUNCTION_NOT_SUPPORTED, ERR_FUNCTION_NOT_SUPPORTED,
@ -24,6 +30,7 @@ from .const import (
from .error import SmartHomeError from .error import SmartHomeError
SYNC_DELAY = 15 SYNC_DELAY = 15
_LOGGER = logging.getLogger(__name__)
class AbstractConfig: class AbstractConfig:
@ -35,6 +42,7 @@ class AbstractConfig:
"""Initialize abstract config.""" """Initialize abstract config."""
self.hass = hass self.hass = hass
self._google_sync_unsub = None self._google_sync_unsub = None
self._local_sdk_active = False
@property @property
def enabled(self): def enabled(self):
@ -61,12 +69,30 @@ class AbstractConfig:
"""Return if we're actively reporting states.""" """Return if we're actively reporting states."""
return self._unsub_report_state is not None return self._unsub_report_state is not None
@property
def is_local_sdk_active(self):
"""Return if we're actively accepting local messages."""
return self._local_sdk_active
@property @property
def should_report_state(self): def should_report_state(self):
"""Return if states should be proactively reported.""" """Return if states should be proactively reported."""
# pylint: disable=no-self-use # pylint: disable=no-self-use
return False return False
@property
def local_sdk_webhook_id(self):
"""Return the local SDK webhook ID.
Return None to disable the local SDK.
"""
return None
@property
def local_sdk_user_id(self):
"""Return the user ID to be used for actions received via the local SDK."""
raise NotImplementedError
def should_expose(self, state) -> bool: def should_expose(self, state) -> bool:
"""Return if entity should be exposed.""" """Return if entity should be exposed."""
raise NotImplementedError raise NotImplementedError
@ -131,15 +157,66 @@ class AbstractConfig:
Called when the user disconnects their account from Google. Called when the user disconnects their account from Google.
""" """
@callback
def async_enable_local_sdk(self):
"""Enable the local SDK."""
webhook_id = self.local_sdk_webhook_id
if webhook_id is None:
return
webhook.async_register(
self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook
)
self._local_sdk_active = True
@callback
def async_disable_local_sdk(self):
"""Disable the local SDK."""
if not self._local_sdk_active:
return
webhook.async_unregister(self.hass, self.local_sdk_webhook_id)
self._local_sdk_active = False
async def _handle_local_webhook(self, hass, webhook_id, request):
"""Handle an incoming local SDK message."""
from . import smart_home
payload = await request.json()
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Received local message:\n%s\n", pprint.pformat(payload))
if not self.enabled:
return json_response(smart_home.turned_off_response(payload))
result = await smart_home.async_handle_message(
self.hass, self, self.local_sdk_user_id, payload
)
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Responding to local message:\n%s\n", pprint.pformat(result))
return json_response(result)
class RequestData: class RequestData:
"""Hold data associated with a particular request.""" """Hold data associated with a particular request."""
def __init__(self, config: AbstractConfig, user_id: str, request_id: str): def __init__(
self,
config: AbstractConfig,
user_id: str,
request_id: str,
devices: Optional[List[dict]],
):
"""Initialize the request data.""" """Initialize the request data."""
self.config = config self.config = config
self.request_id = request_id self.request_id = request_id
self.context = Context(user_id=user_id) self.context = Context(user_id=user_id)
self.devices = devices
def get_google_type(domain, device_class): def get_google_type(domain, device_class):
@ -234,6 +311,15 @@ class GoogleEntity:
if aliases: if aliases:
device["name"]["nicknames"] = aliases device["name"]["nicknames"] = aliases
if self.config.is_local_sdk_active:
device["otherDeviceIds"] = [{"deviceId": self.entity_id}]
device["customData"] = {
"webhookId": self.config.local_sdk_webhook_id,
"httpPort": self.hass.config.api.port,
"httpSSL": self.hass.config.api.use_ssl,
"proxyDeviceId": self.config.agent_user_id,
}
for trt in traits: for trt in traits:
device["attributes"].update(trt.sync_attributes()) device["attributes"].update(trt.sync_attributes())
@ -280,6 +366,11 @@ class GoogleEntity:
return attrs return attrs
@callback
def reachable_device_serialize(self):
"""Serialize entity for a REACHABLE_DEVICE response."""
return {"verificationId": self.entity_id}
async def execute(self, data, command_payload): async def execute(self, data, command_payload):
"""Execute a command. """Execute a command.

View file

@ -5,7 +5,7 @@ import logging
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID, __version__
from .const import ( from .const import (
ERR_PROTOCOL_ERROR, ERR_PROTOCOL_ERROR,
@ -24,9 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_handle_message(hass, config, user_id, message): async def async_handle_message(hass, config, user_id, message):
"""Handle incoming API messages.""" """Handle incoming API messages."""
request_id: str = message.get("requestId") data = RequestData(config, user_id, message["requestId"], message.get("devices"))
data = RequestData(config, user_id, request_id)
response = await _process(hass, data, message) response = await _process(hass, data, message)
@ -67,6 +65,7 @@ async def _process(hass, data, message):
if result is None: if result is None:
return None return None
return {"requestId": data.request_id, "payload": result} return {"requestId": data.request_id, "payload": result}
@ -74,7 +73,7 @@ async def _process(hass, data, message):
async def async_devices_sync(hass, data, payload): async def async_devices_sync(hass, data, payload):
"""Handle action.devices.SYNC request. """Handle action.devices.SYNC request.
https://developers.google.com/actions/smarthome/create-app#actiondevicessync https://developers.google.com/assistant/smarthome/develop/process-intents#SYNC
""" """
hass.bus.async_fire( hass.bus.async_fire(
EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context
@ -84,7 +83,7 @@ async def async_devices_sync(hass, data, payload):
*( *(
entity.sync_serialize() entity.sync_serialize()
for entity in async_get_entities(hass, data.config) for entity in async_get_entities(hass, data.config)
if data.config.should_expose(entity.state) if entity.should_expose()
) )
) )
@ -100,7 +99,7 @@ async def async_devices_sync(hass, data, payload):
async def async_devices_query(hass, data, payload): async def async_devices_query(hass, data, payload):
"""Handle action.devices.QUERY request. """Handle action.devices.QUERY request.
https://developers.google.com/actions/smarthome/create-app#actiondevicesquery https://developers.google.com/assistant/smarthome/develop/process-intents#QUERY
""" """
devices = {} devices = {}
for device in payload.get("devices", []): for device in payload.get("devices", []):
@ -128,7 +127,7 @@ async def async_devices_query(hass, data, payload):
async def handle_devices_execute(hass, data, payload): async def handle_devices_execute(hass, data, payload):
"""Handle action.devices.EXECUTE request. """Handle action.devices.EXECUTE request.
https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute https://developers.google.com/assistant/smarthome/develop/process-intents#EXECUTE
""" """
entities = {} entities = {}
results = {} results = {}
@ -196,12 +195,50 @@ async def handle_devices_execute(hass, data, payload):
async def async_devices_disconnect(hass, data: RequestData, payload): async def async_devices_disconnect(hass, data: RequestData, payload):
"""Handle action.devices.DISCONNECT request. """Handle action.devices.DISCONNECT request.
https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect https://developers.google.com/assistant/smarthome/develop/process-intents#DISCONNECT
""" """
await data.config.async_deactivate_report_state() await data.config.async_deactivate_report_state()
return None return None
@HANDLERS.register("action.devices.IDENTIFY")
async def async_devices_identify(hass, data: RequestData, payload):
"""Handle action.devices.IDENTIFY request.
https://developers.google.com/assistant/smarthome/develop/local#implement_the_identify_handler
"""
return {
"device": {
"id": data.config.agent_user_id,
"isLocalOnly": True,
"isProxy": True,
"deviceInfo": {
"hwVersion": "UNKNOWN_HW_VERSION",
"manufacturer": "Home Assistant",
"model": "Home Assistant",
"swVersion": __version__,
},
}
}
@HANDLERS.register("action.devices.REACHABLE_DEVICES")
async def async_devices_reachable(hass, data: RequestData, payload):
"""Handle action.devices.REACHABLE_DEVICES request.
https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect
"""
google_ids = set(dev["id"] for dev in (data.devices or []))
return {
"devices": [
entity.reachable_device_serialize()
for entity in async_get_entities(hass, data.config)
if entity.entity_id in google_ids and entity.should_expose()
]
}
def turned_off_response(message): def turned_off_response(message):
"""Return a device turned off response.""" """Return a device turned off response."""
return { return {

View file

@ -128,6 +128,7 @@ class ApiConfig:
"""Initialize a new API config object.""" """Initialize a new API config object."""
self.host = host self.host = host
self.port = port self.port = port
self.use_ssl = use_ssl
host = host.rstrip("/") host = host.rstrip("/")
if host.startswith(("http://", "https://")): if host.startswith(("http://", "https://")):

View file

@ -1,7 +1,7 @@
"""Webhooks for Home Assistant.""" """Webhooks for Home Assistant."""
import logging import logging
from aiohttp.web import Response from aiohttp.web import Response, Request
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
@ -98,9 +98,11 @@ class WebhookView(HomeAssistantView):
url = URL_WEBHOOK_PATH url = URL_WEBHOOK_PATH
name = "api:webhook" name = "api:webhook"
requires_auth = False requires_auth = False
cors_allowed = True
async def _handle(self, request, webhook_id): async def _handle(self, request: Request, webhook_id):
"""Handle webhook call.""" """Handle webhook call."""
_LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id)
hass = request.app["hass"] hass = request.app["hass"]
return await async_handle_webhook(hass, webhook_id, request) return await async_handle_webhook(hass, webhook_id, request)

View file

@ -11,7 +11,11 @@ import voluptuous as vol
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
from homeassistant import util from homeassistant import util
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
EVENT_HOMEASSISTANT_START,
__version__,
)
from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -33,6 +37,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
def setup(hass, config): def setup(hass, config):
"""Set up Zeroconf and make Home Assistant discoverable.""" """Set up Zeroconf and make Home Assistant discoverable."""
zeroconf = Zeroconf()
zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}" zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}"
params = { params = {
@ -58,10 +63,16 @@ def setup(hass, config):
properties=params, properties=params,
) )
zeroconf = Zeroconf() def zeroconf_hass_start(_event):
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
"""
_LOGGER.info("Starting Zeroconf broadcast")
zeroconf.register_service(info) zeroconf.register_service(info)
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start)
def service_update(zeroconf, service_type, name, state_change): def service_update(zeroconf, service_type, name, state_change):
"""Service state changed.""" """Service state changed."""
if state_change != ServiceStateChange.Added: if state_change != ServiceStateChange.Added:

View file

@ -230,7 +230,6 @@ def get_test_instance_port():
return _TEST_INSTANCE_PORT return _TEST_INSTANCE_PORT
@ha.callback
def async_mock_service(hass, domain, service, schema=None): def async_mock_service(hass, domain, service, schema=None):
"""Set up a fake service & return a calls log list to this service.""" """Set up a fake service & return a calls log list to this service."""
calls = [] calls = []

View file

@ -25,4 +25,4 @@ def mock_cloud_prefs(hass, prefs={}):
} }
prefs_to_set.update(prefs) prefs_to_set.update(prefs)
hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set
return prefs_to_set return hass.data[cloud.DOMAIN].client._prefs

View file

@ -61,7 +61,7 @@ async def test_handler_alexa(hass):
async def test_handler_alexa_disabled(hass, mock_cloud_fixture): async def test_handler_alexa_disabled(hass, mock_cloud_fixture):
"""Test handler Alexa when user has disabled it.""" """Test handler Alexa when user has disabled it."""
mock_cloud_fixture[PREF_ENABLE_ALEXA] = False mock_cloud_fixture._prefs[PREF_ENABLE_ALEXA] = False
cloud = hass.data["cloud"] cloud = hass.data["cloud"]
resp = await cloud.client.async_alexa_message( resp = await cloud.client.async_alexa_message(
@ -125,7 +125,7 @@ async def test_handler_google_actions(hass):
async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): async def test_handler_google_actions_disabled(hass, mock_cloud_fixture):
"""Test handler Google Actions when user has disabled it.""" """Test handler Google Actions when user has disabled it."""
mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False
with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()): with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()):
assert await async_setup_component(hass, "cloud", {}) assert await async_setup_component(hass, "cloud", {})

View file

@ -10,13 +10,7 @@ from hass_nabucasa.const import STATE_CONNECTED
from homeassistant.core import State from homeassistant.core import State
from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.auth.providers import trusted_networks as tn_auth
from homeassistant.components.cloud.const import ( from homeassistant.components.cloud.const import DOMAIN, RequireRelink
PREF_ENABLE_GOOGLE,
PREF_ENABLE_ALEXA,
PREF_GOOGLE_SECURE_DEVICES_PIN,
DOMAIN,
RequireRelink,
)
from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.alexa.entities import LightCapabilities
from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa import errors as alexa_errors
@ -474,9 +468,9 @@ async def test_websocket_update_preferences(
hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login
): ):
"""Test updating preference.""" """Test updating preference."""
assert setup_api[PREF_ENABLE_GOOGLE] assert setup_api.google_enabled
assert setup_api[PREF_ENABLE_ALEXA] assert setup_api.alexa_enabled
assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] is None assert setup_api.google_secure_devices_pin is None
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json( await client.send_json(
{ {
@ -490,9 +484,9 @@ async def test_websocket_update_preferences(
response = await client.receive_json() response = await client.receive_json()
assert response["success"] assert response["success"]
assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api.google_enabled
assert not setup_api[PREF_ENABLE_ALEXA] assert not setup_api.alexa_enabled
assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] == "1234" assert setup_api.google_secure_devices_pin == "1234"
async def test_websocket_update_preferences_require_relink( async def test_websocket_update_preferences_require_relink(

View file

@ -12,12 +12,23 @@ class MockConfig(helpers.AbstractConfig):
should_expose=None, should_expose=None,
entity_config=None, entity_config=None,
hass=None, hass=None,
local_sdk_webhook_id=None,
local_sdk_user_id=None,
enabled=True,
): ):
"""Initialize config.""" """Initialize config."""
super().__init__(hass) super().__init__(hass)
self._should_expose = should_expose self._should_expose = should_expose
self._secure_devices_pin = secure_devices_pin self._secure_devices_pin = secure_devices_pin
self._entity_config = entity_config or {} self._entity_config = entity_config or {}
self._local_sdk_webhook_id = local_sdk_webhook_id
self._local_sdk_user_id = local_sdk_user_id
self._enabled = enabled
@property
def enabled(self):
"""Return if Google is enabled."""
return self._enabled
@property @property
def secure_devices_pin(self): def secure_devices_pin(self):
@ -29,6 +40,16 @@ class MockConfig(helpers.AbstractConfig):
"""Return secure devices pin.""" """Return secure devices pin."""
return self._entity_config return self._entity_config
@property
def local_sdk_webhook_id(self):
"""Return local SDK webhook id."""
return self._local_sdk_webhook_id
@property
def local_sdk_user_id(self):
"""Return local SDK webhook id."""
return self._local_sdk_user_id
def should_expose(self, state): def should_expose(self, state):
"""Expose it all.""" """Expose it all."""
return self._should_expose is None or self._should_expose(state) return self._should_expose is None or self._should_expose(state)

View file

@ -0,0 +1,130 @@
"""Test Google Assistant helpers."""
from unittest.mock import Mock
from homeassistant.setup import async_setup_component
from homeassistant.components.google_assistant import helpers
from homeassistant.components.google_assistant.const import EVENT_COMMAND_RECEIVED
from . import MockConfig
from tests.common import async_capture_events, async_mock_service
async def test_google_entity_sync_serialize_with_local_sdk(hass):
"""Test sync serialize attributes of a GoogleEntity."""
hass.states.async_set("light.ceiling_lights", "off")
hass.config.api = Mock(port=1234, use_ssl=True)
config = MockConfig(
hass=hass,
local_sdk_webhook_id="mock-webhook-id",
local_sdk_user_id="mock-user-id",
)
entity = helpers.GoogleEntity(hass, config, hass.states.get("light.ceiling_lights"))
serialized = await entity.sync_serialize()
assert "otherDeviceIds" not in serialized
assert "customData" not in serialized
config.async_enable_local_sdk()
serialized = await entity.sync_serialize()
assert serialized["otherDeviceIds"] == [{"deviceId": "light.ceiling_lights"}]
assert serialized["customData"] == {
"httpPort": 1234,
"httpSSL": True,
"proxyDeviceId": None,
"webhookId": "mock-webhook-id",
}
async def test_config_local_sdk(hass, hass_client):
"""Test the local SDK."""
command_events = async_capture_events(hass, EVENT_COMMAND_RECEIVED)
turn_on_calls = async_mock_service(hass, "light", "turn_on")
hass.states.async_set("light.ceiling_lights", "off")
assert await async_setup_component(hass, "webhook", {})
config = MockConfig(
hass=hass,
local_sdk_webhook_id="mock-webhook-id",
local_sdk_user_id="mock-user-id",
)
client = await hass_client()
config.async_enable_local_sdk()
resp = await client.post(
"/api/webhook/mock-webhook-id",
json={
"inputs": [
{
"context": {"locale_country": "US", "locale_language": "en"},
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [
{
"devices": [{"id": "light.ceiling_lights"}],
"execution": [
{
"command": "action.devices.commands.OnOff",
"params": {"on": True},
}
],
}
],
"structureData": {},
},
}
],
"requestId": "mock-req-id",
},
)
assert resp.status == 200
result = await resp.json()
assert result["requestId"] == "mock-req-id"
assert len(command_events) == 1
assert command_events[0].context.user_id == config.local_sdk_user_id
assert len(turn_on_calls) == 1
assert turn_on_calls[0].context is command_events[0].context
config.async_disable_local_sdk()
# Webhook is no longer active
resp = await client.post("/api/webhook/mock-webhook-id")
assert resp.status == 200
assert await resp.read() == b""
async def test_config_local_sdk_if_disabled(hass, hass_client):
"""Test the local SDK."""
assert await async_setup_component(hass, "webhook", {})
config = MockConfig(
hass=hass,
local_sdk_webhook_id="mock-webhook-id",
local_sdk_user_id="mock-user-id",
enabled=False,
)
client = await hass_client()
config.async_enable_local_sdk()
resp = await client.post(
"/api/webhook/mock-webhook-id", json={"requestId": "mock-req-id"}
)
assert resp.status == 200
result = await resp.json()
assert result == {
"payload": {"errorCode": "deviceTurnedOff"},
"requestId": "mock-req-id",
}
config.async_disable_local_sdk()
# Webhook is no longer active
resp = await client.post("/api/webhook/mock-webhook-id")
assert resp.status == 200
assert await resp.read() == b""

View file

@ -3,7 +3,7 @@ from unittest.mock import patch, Mock
import pytest import pytest
from homeassistant.core import State, EVENT_CALL_SERVICE from homeassistant.core import State, EVENT_CALL_SERVICE
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, __version__
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import camera from homeassistant.components import camera
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
@ -734,3 +734,137 @@ async def test_trait_execute_adding_query_data(hass):
] ]
}, },
} }
async def test_identify(hass):
"""Test identify message."""
result = await sh.async_handle_message(
hass,
BASIC_CONFIG,
None,
{
"requestId": REQ_ID,
"inputs": [
{
"intent": "action.devices.IDENTIFY",
"payload": {
"device": {
"mdnsScanData": {
"additionals": [
{
"type": "TXT",
"class": "IN",
"name": "devhome._home-assistant._tcp.local",
"ttl": 4500,
"data": [
"version=0.101.0.dev0",
"base_url=http://192.168.1.101:8123",
"requires_api_password=true",
],
}
]
}
},
"structureData": {},
},
}
],
"devices": [
{
"id": "light.ceiling_lights",
"customData": {
"httpPort": 8123,
"httpSSL": False,
"proxyDeviceId": BASIC_CONFIG.agent_user_id,
"webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219",
},
}
],
},
)
assert result == {
"requestId": REQ_ID,
"payload": {
"device": {
"id": BASIC_CONFIG.agent_user_id,
"isLocalOnly": True,
"isProxy": True,
"deviceInfo": {
"hwVersion": "UNKNOWN_HW_VERSION",
"manufacturer": "Home Assistant",
"model": "Home Assistant",
"swVersion": __version__,
},
}
},
}
async def test_reachable_devices(hass):
"""Test REACHABLE_DEVICES intent."""
# Matching passed in device.
hass.states.async_set("light.ceiling_lights", "on")
# Unsupported entity
hass.states.async_set("not_supported.entity", "something")
# Excluded via config
hass.states.async_set("light.not_expose", "on")
# Not passed in as google_id
hass.states.async_set("light.not_mentioned", "on")
config = MockConfig(
should_expose=lambda state: state.entity_id != "light.not_expose"
)
result = await sh.async_handle_message(
hass,
config,
None,
{
"requestId": REQ_ID,
"inputs": [
{
"intent": "action.devices.REACHABLE_DEVICES",
"payload": {
"device": {
"proxyDevice": {
"id": "6a04f0f7-6125-4356-a846-861df7e01497",
"customData": "{}",
"proxyData": "{}",
}
},
"structureData": {},
},
}
],
"devices": [
{
"id": "light.ceiling_lights",
"customData": {
"httpPort": 8123,
"httpSSL": False,
"proxyDeviceId": BASIC_CONFIG.agent_user_id,
"webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219",
},
},
{
"id": "light.not_expose",
"customData": {
"httpPort": 8123,
"httpSSL": False,
"proxyDeviceId": BASIC_CONFIG.agent_user_id,
"webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219",
},
},
{"id": BASIC_CONFIG.agent_user_id, "customData": {}},
],
},
)
assert result == {
"requestId": REQ_ID,
"payload": {"devices": [{"verificationId": "light.ceiling_lights"}]},
}

View file

@ -48,11 +48,11 @@ _LOGGER = logging.getLogger(__name__)
REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
BASIC_DATA = helpers.RequestData(BASIC_CONFIG, "test-agent", REQ_ID) BASIC_DATA = helpers.RequestData(BASIC_CONFIG, "test-agent", REQ_ID, None)
PIN_CONFIG = MockConfig(secure_devices_pin="1234") PIN_CONFIG = MockConfig(secure_devices_pin="1234")
PIN_DATA = helpers.RequestData(PIN_CONFIG, "test-agent", REQ_ID) PIN_DATA = helpers.RequestData(PIN_CONFIG, "test-agent", REQ_ID, None)
async def test_brightness_light(hass): async def test_brightness_light(hass):