Cloud: Websocket API to manage Google assistant entity config (#24153)

* Extract exposed devices function

* Add might_2fa info to trait

* Do not filter with should_expose in Google helper func

* Cloud: allow setting if Google entity is exposed

* Allow disabling 2FA via config

* Cloud: allow disabling 2FA

* Lint

* More changes

* Fix typing
This commit is contained in:
Paulus Schoutsen 2019-05-29 08:39:12 -07:00 committed by GitHub
parent 85dfea1642
commit 6947f8cb2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 346 additions and 86 deletions

View File

@ -17,7 +17,9 @@ from homeassistant.util.aiohttp import MockRequest
from . import utils from . import utils
from .const import ( from .const import (
CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE,
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
from .prefs import CloudPreferences from .prefs import CloudPreferences
@ -98,12 +100,26 @@ class CloudClient(Interface):
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False return False
return google_conf['filter'](entity.entity_id) if not google_conf['filter'].empty_filter:
return google_conf['filter'](entity.entity_id)
entity_configs = self.prefs.google_entity_configs
entity_config = entity_configs.get(entity.entity_id, {})
return entity_config.get(
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
def should_2fa(entity):
"""If an entity should be checked for 2FA."""
entity_configs = self.prefs.google_entity_configs
entity_config = entity_configs.get(entity.entity_id, {})
return not entity_config.get(
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
username = self._hass.data[DOMAIN].claims["cognito:username"] username = self._hass.data[DOMAIN].claims["cognito:username"]
self._google_config = ga_h.Config( self._google_config = ga_h.Config(
should_expose=should_expose, should_expose=should_expose,
should_2fa=should_2fa,
secure_devices_pin=self._prefs.google_secure_devices_pin, secure_devices_pin=self._prefs.google_secure_devices_pin,
entity_config=google_conf.get(CONF_ENTITY_CONFIG), entity_config=google_conf.get(CONF_ENTITY_CONFIG),
agent_user_id=username, agent_user_id=username,

View File

@ -8,6 +8,13 @@ PREF_ENABLE_REMOTE = 'remote_enabled'
PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin' PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin'
PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUDHOOKS = 'cloudhooks'
PREF_CLOUD_USER = 'cloud_user' PREF_CLOUD_USER = 'cloud_user'
PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs'
PREF_OVERRIDE_NAME = 'override_name'
PREF_DISABLE_2FA = 'disable_2fa'
PREF_ALIASES = 'aliases'
PREF_SHOULD_EXPOSE = 'should_expose'
DEFAULT_SHOULD_EXPOSE = True
DEFAULT_DISABLE_2FA = False
CONF_ALEXA = 'alexa' CONF_ALEXA = 'alexa'
CONF_ALIASES = 'aliases' CONF_ALIASES = 'aliases'

View File

@ -14,8 +14,7 @@ from homeassistant.components.http.data_validator import (
RequestDataValidator) RequestDataValidator)
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import ( from homeassistant.components.google_assistant import helpers as google_helpers
const as google_const)
from .const import ( from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
@ -81,6 +80,12 @@ async def async_setup(hass):
websocket_remote_connect) websocket_remote_connect)
hass.components.websocket_api.async_register_command( hass.components.websocket_api.async_register_command(
websocket_remote_disconnect) websocket_remote_disconnect)
hass.components.websocket_api.async_register_command(
google_assistant_list)
hass.components.websocket_api.async_register_command(
google_assistant_update)
hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLoginView)
hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudLogoutView)
@ -411,7 +416,6 @@ def _account_data(cloud):
'cloud': cloud.iot.state, 'cloud': cloud.iot.state,
'prefs': client.prefs.as_dict(), 'prefs': client.prefs.as_dict(),
'google_entities': client.google_user_config['filter'].config, 'google_entities': client.google_user_config['filter'].config,
'google_domains': list(google_const.DOMAIN_TO_GOOGLE_TYPES),
'alexa_entities': client.alexa_config.should_expose.config, 'alexa_entities': client.alexa_config.should_expose.config,
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
'remote_domain': remote.instance_domain, 'remote_domain': remote.instance_domain,
@ -448,3 +452,55 @@ async def websocket_remote_disconnect(hass, connection, msg):
await cloud.client.prefs.async_update(remote_enabled=False) await cloud.client.prefs.async_update(remote_enabled=False)
await cloud.remote.disconnect() await cloud.remote.disconnect()
connection.send_result(msg['id'], _account_data(cloud)) connection.send_result(msg['id'], _account_data(cloud))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/google_assistant/entities'
})
async def google_assistant_list(hass, connection, msg):
"""List all google assistant entities."""
cloud = hass.data[DOMAIN]
entities = google_helpers.async_get_entities(
hass, cloud.client.google_config
)
result = []
for entity in entities:
result.append({
'entity_id': entity.entity_id,
'traits': [trait.name for trait in entity.traits()],
'might_2fa': entity.might_2fa(),
})
connection.send_result(msg['id'], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/google_assistant/entities/update',
'entity_id': str,
vol.Optional('should_expose'): bool,
vol.Optional('override_name'): str,
vol.Optional('aliases'): [str],
vol.Optional('disable_2fa'): bool,
})
async def google_assistant_update(hass, connection, msg):
"""List all google assistant entities."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop('type')
changes.pop('id')
await cloud.client.prefs.async_update_google_entity_config(**changes)
connection.send_result(
msg['id'],
cloud.client.prefs.google_entity_configs.get(msg['entity_id']))

View File

@ -4,6 +4,8 @@ from ipaddress import ip_address
from .const import ( from .const import (
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA,
PREF_ALIASES, PREF_SHOULD_EXPOSE,
InvalidTrustedNetworks) InvalidTrustedNetworks)
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
@ -30,6 +32,7 @@ class CloudPreferences:
PREF_ENABLE_GOOGLE: True, PREF_ENABLE_GOOGLE: True,
PREF_ENABLE_REMOTE: False, PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_CLOUDHOOKS: {}, PREF_CLOUDHOOKS: {},
PREF_CLOUD_USER: None, PREF_CLOUD_USER: None,
} }
@ -39,7 +42,7 @@ class CloudPreferences:
async def async_update(self, *, google_enabled=_UNDEF, async def async_update(self, *, google_enabled=_UNDEF,
alexa_enabled=_UNDEF, remote_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF,
cloud_user=_UNDEF): cloud_user=_UNDEF, google_entity_configs=_UNDEF):
"""Update user preferences.""" """Update user preferences."""
for key, value in ( for key, value in (
(PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_GOOGLE, google_enabled),
@ -48,6 +51,7 @@ class CloudPreferences:
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user), (PREF_CLOUD_USER, cloud_user),
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
): ):
if value is not _UNDEF: if value is not _UNDEF:
self._prefs[key] = value self._prefs[key] = value
@ -57,9 +61,48 @@ class CloudPreferences:
await self._store.async_save(self._prefs) await self._store.async_save(self._prefs)
async def async_update_google_entity_config(
self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF,
aliases=_UNDEF, should_expose=_UNDEF):
"""Update config for a Google entity."""
entities = self.google_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in (
(PREF_OVERRIDE_NAME, override_name),
(PREF_DISABLE_2FA, disable_2fa),
(PREF_ALIASES, aliases),
(PREF_SHOULD_EXPOSE, should_expose),
):
if value is not _UNDEF:
changes[key] = value
if not changes:
return
updated_entity = {
**entity,
**changes,
}
updated_entities = {
**entities,
entity_id: updated_entity,
}
await self.async_update(google_entity_configs=updated_entities)
def as_dict(self): def as_dict(self):
"""Return dictionary version.""" """Return dictionary version."""
return self._prefs return {
PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_CLOUD_USER: self.cloud_user,
}
@property @property
def remote_enabled(self): def remote_enabled(self):
@ -89,6 +132,11 @@ class CloudPreferences:
"""Return if Google is allowed to unlock locks.""" """Return if Google is allowed to unlock locks."""
return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN)
@property
def google_entity_configs(self):
"""Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property @property
def cloudhooks(self): def cloudhooks(self):
"""Return the published cloud webhooks.""" """Return the published cloud webhooks."""

View File

@ -1,17 +1,18 @@
"""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
from homeassistant.core import Context, callback from homeassistant.core import Context, callback
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES, CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES,
ATTR_DEVICE_CLASS ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES
) )
from . import trait from . import trait
from .const import ( from .const import (
DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED,
DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT, DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT
) )
from .error import SmartHomeError from .error import SmartHomeError
@ -21,15 +22,20 @@ class Config:
def __init__(self, should_expose, def __init__(self, should_expose,
entity_config=None, secure_devices_pin=None, entity_config=None, secure_devices_pin=None,
agent_user_id=None): agent_user_id=None, should_2fa=None):
"""Initialize the configuration.""" """Initialize the configuration."""
self.should_expose = should_expose self.should_expose = should_expose
self.entity_config = entity_config or {} self.entity_config = entity_config or {}
self.secure_devices_pin = secure_devices_pin self.secure_devices_pin = secure_devices_pin
self._should_2fa = should_2fa
# Agent User Id to use for query responses # Agent User Id to use for query responses
self.agent_user_id = agent_user_id self.agent_user_id = agent_user_id
def should_2fa(self, state):
"""If an entity should have 2FA checked."""
return self._should_2fa is None or self._should_2fa(state)
class RequestData: class RequestData:
"""Hold data associated with a particular request.""" """Hold data associated with a particular request."""
@ -79,6 +85,22 @@ class GoogleEntity:
if Trait.supported(domain, features, device_class)] if Trait.supported(domain, features, device_class)]
return self._traits return self._traits
@callback
def is_supported(self) -> bool:
"""Return if the entity is supported by Google."""
return self.state.state != STATE_UNAVAILABLE and bool(self.traits())
@callback
def might_2fa(self) -> bool:
"""Return if the entity might encounter 2FA."""
state = self.state
domain = state.domain
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
return any(trait.might_2fa(domain, features, device_class)
for trait in self.traits())
async def sync_serialize(self): async def sync_serialize(self):
"""Serialize entity for a SYNC response. """Serialize entity for a SYNC response.
@ -86,27 +108,13 @@ class GoogleEntity:
""" """
state = self.state state = self.state
# When a state is unavailable, the attributes that describe
# capabilities will be stripped. For example, a light entity will miss
# the min/max mireds. Therefore they will be excluded from a sync.
if state.state == STATE_UNAVAILABLE:
return None
entity_config = self.config.entity_config.get(state.entity_id, {}) entity_config = self.config.entity_config.get(state.entity_id, {})
name = (entity_config.get(CONF_NAME) or state.name).strip() name = (entity_config.get(CONF_NAME) or state.name).strip()
domain = state.domain domain = state.domain
device_class = state.attributes.get(ATTR_DEVICE_CLASS) device_class = state.attributes.get(ATTR_DEVICE_CLASS)
# If an empty string
if not name:
return None
traits = self.traits() traits = self.traits()
# Found no supported traits for this entity
if not traits:
return None
device_type = get_google_type(domain, device_type = get_google_type(domain,
device_class) device_class)
@ -213,3 +221,19 @@ def deep_update(target, source):
else: else:
target[key] = value target[key] = value
return target return target
@callback
def async_get_entities(hass, config) -> List[GoogleEntity]:
"""Return all entities that are supported by Google."""
entities = []
for state in hass.states.async_all():
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
entity = GoogleEntity(hass, config, state)
if entity.is_supported():
entities.append(entity)
return entities

View File

@ -1,17 +1,17 @@
"""Support for Google Assistant Smart Home API.""" """Support for Google Assistant Smart Home API."""
import asyncio
from itertools import product from itertools import product
import logging import logging
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.const import ( from homeassistant.const import ATTR_ENTITY_ID
CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID)
from .const import ( from .const import (
ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR,
EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED
) )
from .helpers import RequestData, GoogleEntity from .helpers import RequestData, GoogleEntity, async_get_entities
from .error import SmartHomeError from .error import SmartHomeError
HANDLERS = Registry() HANDLERS = Registry()
@ -81,22 +81,11 @@ async def async_devices_sync(hass, data, payload):
{'request_id': data.request_id}, {'request_id': data.request_id},
context=data.context) context=data.context)
devices = [] devices = await asyncio.gather(*[
for state in hass.states.async_all(): entity.sync_serialize() for entity in
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: async_get_entities(hass, data.config)
continue if data.config.should_expose(entity.state)
])
if not data.config.should_expose(state):
continue
entity = GoogleEntity(hass, data.config, state)
serialized = await entity.sync_serialize()
if serialized is None:
_LOGGER.debug("No mapping for %s domain", entity.state)
continue
devices.append(serialized)
response = { response = {
'agentUserId': data.config.agent_user_id or data.context.user_id, 'agentUserId': data.config.agent_user_id or data.context.user_id,

View File

@ -104,6 +104,11 @@ class _Trait:
commands = [] commands = []
@staticmethod
def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return False
def __init__(self, hass, state, config): def __init__(self, hass, state, config):
"""Initialize a trait for a state.""" """Initialize a trait for a state."""
self.hass = hass self.hass = hass
@ -732,6 +737,11 @@ class LockUnlockTrait(_Trait):
"""Test if state is supported.""" """Test if state is supported."""
return domain == lock.DOMAIN return domain == lock.DOMAIN
@staticmethod
def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return True
def sync_attributes(self): def sync_attributes(self):
"""Return LockUnlock attributes for a sync request.""" """Return LockUnlock attributes for a sync request."""
return {} return {}
@ -745,7 +755,7 @@ class LockUnlockTrait(_Trait):
if params['lock']: if params['lock']:
service = lock.SERVICE_LOCK service = lock.SERVICE_LOCK
else: else:
_verify_pin_challenge(data, challenge) _verify_pin_challenge(data, self.state, challenge)
service = lock.SERVICE_UNLOCK service = lock.SERVICE_UNLOCK
await self.hass.services.async_call(lock.DOMAIN, service, { await self.hass.services.async_call(lock.DOMAIN, service, {
@ -1021,6 +1031,9 @@ class OpenCloseTrait(_Trait):
https://developers.google.com/actions/smarthome/traits/openclose https://developers.google.com/actions/smarthome/traits/openclose
""" """
# Cover device classes that require 2FA
COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE)
name = TRAIT_OPENCLOSE name = TRAIT_OPENCLOSE
commands = [ commands = [
COMMAND_OPENCLOSE COMMAND_OPENCLOSE
@ -1042,6 +1055,12 @@ class OpenCloseTrait(_Trait):
binary_sensor.DEVICE_CLASS_WINDOW, binary_sensor.DEVICE_CLASS_WINDOW,
) )
@staticmethod
def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return (domain == cover.DOMAIN and
device_class in OpenCloseTrait.COVER_2FA)
def sync_attributes(self): def sync_attributes(self):
"""Return opening direction.""" """Return opening direction."""
response = {} response = {}
@ -1114,9 +1133,8 @@ class OpenCloseTrait(_Trait):
if (should_verify and if (should_verify and
self.state.attributes.get(ATTR_DEVICE_CLASS) self.state.attributes.get(ATTR_DEVICE_CLASS)
in (cover.DEVICE_CLASS_DOOR, in OpenCloseTrait.COVER_2FA):
cover.DEVICE_CLASS_GARAGE)): _verify_pin_challenge(data, self.state, challenge)
_verify_pin_challenge(data, challenge)
await self.hass.services.async_call( await self.hass.services.async_call(
cover.DOMAIN, service, svc_params, cover.DOMAIN, service, svc_params,
@ -1202,8 +1220,11 @@ class VolumeTrait(_Trait):
ERR_NOT_SUPPORTED, 'Command not supported') ERR_NOT_SUPPORTED, 'Command not supported')
def _verify_pin_challenge(data, challenge): def _verify_pin_challenge(data, state, challenge):
"""Verify a pin challenge.""" """Verify a pin challenge."""
if not data.config.should_2fa(state):
return
if not data.config.secure_devices_pin: if not data.config.secure_devices_pin:
raise SmartHomeError( raise SmartHomeError(
ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up') ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up')
@ -1217,7 +1238,7 @@ def _verify_pin_challenge(data, challenge):
raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED)
def _verify_ack_challenge(data, challenge): def _verify_ack_challenge(data, state, challenge):
"""Verify a pin challenge.""" """Verify a pin challenge."""
if not challenge or not challenge.get('ack'): if not challenge or not challenge.get('ack'):
raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) raise ChallengeNeeded(CHALLENGE_ACK_NEEDED)

View File

@ -1,5 +1,5 @@
"""Helper class to implement include/exclude of entities and domains.""" """Helper class to implement include/exclude of entities and domains."""
from typing import Callable, Dict, Iterable from typing import Callable, Dict, List
import voluptuous as vol import voluptuous as vol
@ -12,7 +12,7 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains'
CONF_EXCLUDE_ENTITIES = 'exclude_entities' CONF_EXCLUDE_ENTITIES = 'exclude_entities'
def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]:
filt = generate_filter( filt = generate_filter(
config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_DOMAINS],
config[CONF_INCLUDE_ENTITIES], config[CONF_INCLUDE_ENTITIES],
@ -20,6 +20,8 @@ def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]:
config[CONF_EXCLUDE_ENTITIES], config[CONF_EXCLUDE_ENTITIES],
) )
setattr(filt, 'config', config) setattr(filt, 'config', config)
setattr(
filt, 'empty_filter', sum(len(val) for val in config.values()) == 0)
return filt return filt
@ -34,10 +36,10 @@ FILTER_SCHEMA = vol.All(
}), _convert_filter) }), _convert_filter)
def generate_filter(include_domains: Iterable[str], def generate_filter(include_domains: List[str],
include_entities: Iterable[str], include_entities: List[str],
exclude_domains: Iterable[str], exclude_domains: List[str],
exclude_entities: Iterable[str]) -> Callable[[str], bool]: exclude_entities: List[str]) -> Callable[[str], bool]:
"""Return a function that will filter entities based on the args.""" """Return a function that will filter entities based on the args."""
include_d = set(include_domains) include_d = set(include_domains)
include_e = set(include_entities) include_e = set(include_entities)

View File

@ -2,9 +2,12 @@
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from aiohttp import web from aiohttp import web
import jwt
import pytest import pytest
from homeassistant.core import State
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components.cloud import DOMAIN
from homeassistant.components.cloud.const import ( from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
from tests.components.alexa import test_smart_home as test_alexa from tests.components.alexa import test_smart_home as test_alexa
@ -19,6 +22,25 @@ def mock_cloud():
return MagicMock(subscription_expired=False) return MagicMock(subscription_expired=False)
@pytest.fixture
async def mock_cloud_setup(hass):
"""Set up the cloud."""
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
assert await async_setup_component(hass, 'cloud', {
'cloud': {}
})
@pytest.fixture
def mock_cloud_login(hass, mock_cloud_setup):
"""Mock cloud is logged in."""
hass.data[DOMAIN].id_token = jwt.encode({
'email': 'hello@home-assistant.io',
'custom:sub-exp': '2018-01-03',
'cognito:username': 'abcdefghjkl',
}, 'test')
async def test_handler_alexa(hass): async def test_handler_alexa(hass):
"""Test handler Alexa.""" """Test handler Alexa."""
hass.states.async_set( hass.states.async_set(
@ -197,3 +219,35 @@ async def test_webhook_msg(hass):
assert await received[0].json() == { assert await received[0].json() == {
'hello': 'world' 'hello': 'world'
} }
async def test_google_config_expose_entity(
hass, mock_cloud_setup, mock_cloud_login):
"""Test Google config exposing entity method uses latest config."""
cloud_client = hass.data[DOMAIN].client
state = State('light.kitchen', 'on')
assert cloud_client.google_config.should_expose(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id='light.kitchen',
should_expose=False,
)
assert not cloud_client.google_config.should_expose(state)
async def test_google_config_should_2fa(
hass, mock_cloud_setup, mock_cloud_login):
"""Test Google config disabling 2FA method uses latest config."""
cloud_client = hass.data[DOMAIN].client
state = State('light.kitchen', 'on')
assert cloud_client.google_config.should_2fa(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id='light.kitchen',
disable_2fa=True,
)
assert not cloud_client.google_config.should_2fa(state)

View File

@ -7,10 +7,13 @@ from jose import jwt
from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.auth import Unauthenticated, UnknownError
from hass_nabucasa.const import STATE_CONNECTED from hass_nabucasa.const import STATE_CONNECTED
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 (
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN,
DOMAIN) DOMAIN)
from homeassistant.components.google_assistant.helpers import (
GoogleEntity, Config)
from tests.common import mock_coro from tests.common import mock_coro
@ -32,7 +35,8 @@ def mock_cloud_login(hass, setup_api):
"""Mock cloud is logged in.""" """Mock cloud is logged in."""
hass.data[DOMAIN].id_token = jwt.encode({ hass.data[DOMAIN].id_token = jwt.encode({
'email': 'hello@home-assistant.io', 'email': 'hello@home-assistant.io',
'custom:sub-exp': '2018-01-03' 'custom:sub-exp': '2018-01-03',
'cognito:username': 'abcdefghjkl',
}, 'test') }, 'test')
@ -349,7 +353,15 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
'logged_in': True, 'logged_in': True,
'email': 'hello@home-assistant.io', 'email': 'hello@home-assistant.io',
'cloud': 'connected', 'cloud': 'connected',
'prefs': mock_cloud_fixture, 'prefs': {
'alexa_enabled': True,
'cloud_user': None,
'cloudhooks': {},
'google_enabled': True,
'google_entity_configs': {},
'google_secure_devices_pin': None,
'remote_enabled': False,
},
'alexa_entities': { 'alexa_entities': {
'include_domains': [], 'include_domains': [],
'include_entities': ['light.kitchen', 'switch.ac'], 'include_entities': ['light.kitchen', 'switch.ac'],
@ -363,7 +375,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
'exclude_domains': [], 'exclude_domains': [],
'exclude_entities': [], 'exclude_entities': [],
}, },
'google_domains': ['light'],
'remote_domain': None, 'remote_domain': None,
'remote_connected': False, 'remote_connected': False,
'remote_certificate': None, 'remote_certificate': None,
@ -689,3 +700,52 @@ async def test_enabling_remote_trusted_networks_other(
assert cloud.client.remote_autostart assert cloud.client.remote_autostart
assert len(mock_connect.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1
async def test_list_google_entities(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test that we can list Google entities."""
client = await hass_ws_client(hass)
entity = GoogleEntity(hass, Config(lambda *_: False), State(
'light.kitchen', 'on'
))
with patch('homeassistant.components.google_assistant.helpers'
'.async_get_entities', return_value=[entity]):
await client.send_json({
'id': 5,
'type': 'cloud/google_assistant/entities',
})
response = await client.receive_json()
assert response['success']
assert len(response['result']) == 1
assert response['result'][0] == {
'entity_id': 'light.kitchen',
'might_2fa': False,
'traits': ['action.devices.traits.OnOff'],
}
async def test_update_google_entity(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test that we can update config of a Google entity."""
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
'type': 'cloud/google_assistant/entities/update',
'entity_id': 'light.kitchen',
'should_expose': False,
'override_name': 'updated name',
'aliases': ['lefty', 'righty'],
'disable_2fa': False,
})
response = await client.receive_json()
assert response['success']
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs['light.kitchen'] == {
'should_expose': False,
'override_name': 'updated name',
'aliases': ['lefty', 'righty'],
'disable_2fa': False,
}

View File

@ -530,34 +530,6 @@ async def test_unavailable_state_doesnt_sync(hass):
} }
async def test_empty_name_doesnt_sync(hass):
"""Test that an entity with empty name does not sync over."""
light = DemoLight(
None, ' ',
state=False,
)
light.hass = hass
light.entity_id = 'light.demo_light'
await light.async_update_ha_state()
result = await sh.async_handle_message(
hass, BASIC_CONFIG, 'test-agent',
{
"requestId": REQ_ID,
"inputs": [{
"intent": "action.devices.SYNC"
}]
})
assert result == {
'requestId': REQ_ID,
'payload': {
'agentUserId': 'test-agent',
'devices': []
}
}
@pytest.mark.parametrize("device_class,google_type", [ @pytest.mark.parametrize("device_class,google_type", [
('non_existing_class', 'action.devices.types.SWITCH'), ('non_existing_class', 'action.devices.types.SWITCH'),
('switch', 'action.devices.types.SWITCH'), ('switch', 'action.devices.types.SWITCH'),

View File

@ -843,6 +843,8 @@ async def test_lock_unlock_lock(hass):
assert helpers.get_google_type(lock.DOMAIN, None) is not None assert helpers.get_google_type(lock.DOMAIN, None) is not None
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
None) None)
assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN,
None)
trt = trait.LockUnlockTrait(hass, trt = trait.LockUnlockTrait(hass,
State('lock.front_door', lock.STATE_LOCKED), State('lock.front_door', lock.STATE_LOCKED),
@ -922,6 +924,13 @@ async def test_lock_unlock_unlock(hass):
assert len(calls) == 1 assert len(calls) == 1
assert err.value.code == const.ERR_CHALLENGE_NOT_SETUP assert err.value.code == const.ERR_CHALLENGE_NOT_SETUP
# Test with 2FA override
with patch('homeassistant.components.google_assistant.helpers'
'.Config.should_2fa', return_value=False):
await trt.execute(
trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {})
assert len(calls) == 2
async def test_fan_speed(hass): async def test_fan_speed(hass):
"""Test FanSpeed trait speed control support for fan domain.""" """Test FanSpeed trait speed control support for fan domain."""
@ -1216,6 +1225,8 @@ async def test_openclose_cover_secure(hass, device_class):
assert helpers.get_google_type(cover.DOMAIN, device_class) is not None assert helpers.get_google_type(cover.DOMAIN, device_class) is not None
assert trait.OpenCloseTrait.supported( assert trait.OpenCloseTrait.supported(
cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class)
assert trait.OpenCloseTrait.might_2fa(
cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class)
trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, { trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
ATTR_DEVICE_CLASS: device_class, ATTR_DEVICE_CLASS: device_class,