mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
parent
85dfea1642
commit
6947f8cb2e
@ -17,7 +17,9 @@ from homeassistant.util.aiohttp import MockRequest
|
||||
|
||||
from . import utils
|
||||
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
|
||||
|
||||
|
||||
@ -98,12 +100,26 @@ class CloudClient(Interface):
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
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"]
|
||||
|
||||
self._google_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
should_2fa=should_2fa,
|
||||
secure_devices_pin=self._prefs.google_secure_devices_pin,
|
||||
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
|
||||
agent_user_id=username,
|
||||
|
@ -8,6 +8,13 @@ PREF_ENABLE_REMOTE = 'remote_enabled'
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin'
|
||||
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||
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_ALIASES = 'aliases'
|
||||
|
@ -14,8 +14,7 @@ from homeassistant.components.http.data_validator import (
|
||||
RequestDataValidator)
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import (
|
||||
const as google_const)
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
|
||||
from .const import (
|
||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
@ -81,6 +80,12 @@ async def async_setup(hass):
|
||||
websocket_remote_connect)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
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(CloudLoginView)
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
@ -411,7 +416,6 @@ def _account_data(cloud):
|
||||
'cloud': cloud.iot.state,
|
||||
'prefs': client.prefs.as_dict(),
|
||||
'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_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
||||
'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.remote.disconnect()
|
||||
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']))
|
||||
|
@ -4,6 +4,8 @@ from ipaddress import ip_address
|
||||
from .const import (
|
||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
|
||||
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)
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
@ -30,6 +32,7 @@ class CloudPreferences:
|
||||
PREF_ENABLE_GOOGLE: True,
|
||||
PREF_ENABLE_REMOTE: False,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
|
||||
PREF_GOOGLE_ENTITY_CONFIGS: {},
|
||||
PREF_CLOUDHOOKS: {},
|
||||
PREF_CLOUD_USER: None,
|
||||
}
|
||||
@ -39,7 +42,7 @@ class CloudPreferences:
|
||||
async def async_update(self, *, google_enabled=_UNDEF,
|
||||
alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
|
||||
google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF,
|
||||
cloud_user=_UNDEF):
|
||||
cloud_user=_UNDEF, google_entity_configs=_UNDEF):
|
||||
"""Update user preferences."""
|
||||
for key, value in (
|
||||
(PREF_ENABLE_GOOGLE, google_enabled),
|
||||
@ -48,6 +51,7 @@ class CloudPreferences:
|
||||
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
|
||||
(PREF_CLOUDHOOKS, cloudhooks),
|
||||
(PREF_CLOUD_USER, cloud_user),
|
||||
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
|
||||
):
|
||||
if value is not _UNDEF:
|
||||
self._prefs[key] = value
|
||||
@ -57,9 +61,48 @@ class CloudPreferences:
|
||||
|
||||
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):
|
||||
"""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
|
||||
def remote_enabled(self):
|
||||
@ -89,6 +132,11 @@ class CloudPreferences:
|
||||
"""Return if Google is allowed to unlock locks."""
|
||||
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
|
||||
def cloudhooks(self):
|
||||
"""Return the published cloud webhooks."""
|
||||
|
@ -1,17 +1,18 @@
|
||||
"""Helper classes for Google Assistant integration."""
|
||||
from asyncio import gather
|
||||
from collections.abc import Mapping
|
||||
from typing import List
|
||||
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_DEVICE_CLASS
|
||||
ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
)
|
||||
|
||||
from . import trait
|
||||
from .const import (
|
||||
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
|
||||
|
||||
@ -21,15 +22,20 @@ class Config:
|
||||
|
||||
def __init__(self, should_expose,
|
||||
entity_config=None, secure_devices_pin=None,
|
||||
agent_user_id=None):
|
||||
agent_user_id=None, should_2fa=None):
|
||||
"""Initialize the configuration."""
|
||||
self.should_expose = should_expose
|
||||
self.entity_config = entity_config or {}
|
||||
self.secure_devices_pin = secure_devices_pin
|
||||
self._should_2fa = should_2fa
|
||||
|
||||
# Agent User Id to use for query responses
|
||||
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:
|
||||
"""Hold data associated with a particular request."""
|
||||
@ -79,6 +85,22 @@ class GoogleEntity:
|
||||
if Trait.supported(domain, features, device_class)]
|
||||
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):
|
||||
"""Serialize entity for a SYNC response.
|
||||
|
||||
@ -86,27 +108,13 @@ class GoogleEntity:
|
||||
"""
|
||||
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, {})
|
||||
name = (entity_config.get(CONF_NAME) or state.name).strip()
|
||||
domain = state.domain
|
||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
|
||||
# If an empty string
|
||||
if not name:
|
||||
return None
|
||||
|
||||
traits = self.traits()
|
||||
|
||||
# Found no supported traits for this entity
|
||||
if not traits:
|
||||
return None
|
||||
|
||||
device_type = get_google_type(domain,
|
||||
device_class)
|
||||
|
||||
@ -213,3 +221,19 @@ def deep_update(target, source):
|
||||
else:
|
||||
target[key] = value
|
||||
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
|
||||
|
@ -1,17 +1,17 @@
|
||||
"""Support for Google Assistant Smart Home API."""
|
||||
import asyncio
|
||||
from itertools import product
|
||||
import logging
|
||||
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from homeassistant.const import (
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
|
||||
from .const import (
|
||||
ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR,
|
||||
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
|
||||
|
||||
HANDLERS = Registry()
|
||||
@ -81,22 +81,11 @@ async def async_devices_sync(hass, data, payload):
|
||||
{'request_id': data.request_id},
|
||||
context=data.context)
|
||||
|
||||
devices = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
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)
|
||||
devices = await asyncio.gather(*[
|
||||
entity.sync_serialize() for entity in
|
||||
async_get_entities(hass, data.config)
|
||||
if data.config.should_expose(entity.state)
|
||||
])
|
||||
|
||||
response = {
|
||||
'agentUserId': data.config.agent_user_id or data.context.user_id,
|
||||
|
@ -104,6 +104,11 @@ class _Trait:
|
||||
|
||||
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):
|
||||
"""Initialize a trait for a state."""
|
||||
self.hass = hass
|
||||
@ -732,6 +737,11 @@ class LockUnlockTrait(_Trait):
|
||||
"""Test if state is supported."""
|
||||
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):
|
||||
"""Return LockUnlock attributes for a sync request."""
|
||||
return {}
|
||||
@ -745,7 +755,7 @@ class LockUnlockTrait(_Trait):
|
||||
if params['lock']:
|
||||
service = lock.SERVICE_LOCK
|
||||
else:
|
||||
_verify_pin_challenge(data, challenge)
|
||||
_verify_pin_challenge(data, self.state, challenge)
|
||||
service = lock.SERVICE_UNLOCK
|
||||
|
||||
await self.hass.services.async_call(lock.DOMAIN, service, {
|
||||
@ -1021,6 +1031,9 @@ class OpenCloseTrait(_Trait):
|
||||
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
|
||||
commands = [
|
||||
COMMAND_OPENCLOSE
|
||||
@ -1042,6 +1055,12 @@ class OpenCloseTrait(_Trait):
|
||||
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):
|
||||
"""Return opening direction."""
|
||||
response = {}
|
||||
@ -1114,9 +1133,8 @@ class OpenCloseTrait(_Trait):
|
||||
|
||||
if (should_verify and
|
||||
self.state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
in (cover.DEVICE_CLASS_DOOR,
|
||||
cover.DEVICE_CLASS_GARAGE)):
|
||||
_verify_pin_challenge(data, challenge)
|
||||
in OpenCloseTrait.COVER_2FA):
|
||||
_verify_pin_challenge(data, self.state, challenge)
|
||||
|
||||
await self.hass.services.async_call(
|
||||
cover.DOMAIN, service, svc_params,
|
||||
@ -1202,8 +1220,11 @@ class VolumeTrait(_Trait):
|
||||
ERR_NOT_SUPPORTED, 'Command not supported')
|
||||
|
||||
|
||||
def _verify_pin_challenge(data, challenge):
|
||||
def _verify_pin_challenge(data, state, challenge):
|
||||
"""Verify a pin challenge."""
|
||||
if not data.config.should_2fa(state):
|
||||
return
|
||||
|
||||
if not data.config.secure_devices_pin:
|
||||
raise SmartHomeError(
|
||||
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)
|
||||
|
||||
|
||||
def _verify_ack_challenge(data, challenge):
|
||||
def _verify_ack_challenge(data, state, challenge):
|
||||
"""Verify a pin challenge."""
|
||||
if not challenge or not challenge.get('ack'):
|
||||
raise ChallengeNeeded(CHALLENGE_ACK_NEEDED)
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""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
|
||||
|
||||
@ -12,7 +12,7 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains'
|
||||
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(
|
||||
config[CONF_INCLUDE_DOMAINS],
|
||||
config[CONF_INCLUDE_ENTITIES],
|
||||
@ -20,6 +20,8 @@ def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]:
|
||||
config[CONF_EXCLUDE_ENTITIES],
|
||||
)
|
||||
setattr(filt, 'config', config)
|
||||
setattr(
|
||||
filt, 'empty_filter', sum(len(val) for val in config.values()) == 0)
|
||||
return filt
|
||||
|
||||
|
||||
@ -34,10 +36,10 @@ FILTER_SCHEMA = vol.All(
|
||||
}), _convert_filter)
|
||||
|
||||
|
||||
def generate_filter(include_domains: Iterable[str],
|
||||
include_entities: Iterable[str],
|
||||
exclude_domains: Iterable[str],
|
||||
exclude_entities: Iterable[str]) -> Callable[[str], bool]:
|
||||
def generate_filter(include_domains: List[str],
|
||||
include_entities: List[str],
|
||||
exclude_domains: List[str],
|
||||
exclude_entities: List[str]) -> Callable[[str], bool]:
|
||||
"""Return a function that will filter entities based on the args."""
|
||||
include_d = set(include_domains)
|
||||
include_e = set(include_entities)
|
||||
|
@ -2,9 +2,12 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from aiohttp import web
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import State
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.cloud import DOMAIN
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
|
||||
from tests.components.alexa import test_smart_home as test_alexa
|
||||
@ -19,6 +22,25 @@ def mock_cloud():
|
||||
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):
|
||||
"""Test handler Alexa."""
|
||||
hass.states.async_set(
|
||||
@ -197,3 +219,35 @@ async def test_webhook_msg(hass):
|
||||
assert await received[0].json() == {
|
||||
'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)
|
||||
|
@ -7,10 +7,13 @@ from jose import jwt
|
||||
from hass_nabucasa.auth import Unauthenticated, UnknownError
|
||||
from hass_nabucasa.const import STATE_CONNECTED
|
||||
|
||||
from homeassistant.core import State
|
||||
from homeassistant.auth.providers import trusted_networks as tn_auth
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
DOMAIN)
|
||||
from homeassistant.components.google_assistant.helpers import (
|
||||
GoogleEntity, Config)
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
@ -32,7 +35,8 @@ def mock_cloud_login(hass, setup_api):
|
||||
"""Mock cloud is logged in."""
|
||||
hass.data[DOMAIN].id_token = jwt.encode({
|
||||
'email': 'hello@home-assistant.io',
|
||||
'custom:sub-exp': '2018-01-03'
|
||||
'custom:sub-exp': '2018-01-03',
|
||||
'cognito:username': 'abcdefghjkl',
|
||||
}, 'test')
|
||||
|
||||
|
||||
@ -349,7 +353,15 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
|
||||
'logged_in': True,
|
||||
'email': 'hello@home-assistant.io',
|
||||
'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': {
|
||||
'include_domains': [],
|
||||
'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_entities': [],
|
||||
},
|
||||
'google_domains': ['light'],
|
||||
'remote_domain': None,
|
||||
'remote_connected': False,
|
||||
'remote_certificate': None,
|
||||
@ -689,3 +700,52 @@ async def test_enabling_remote_trusted_networks_other(
|
||||
assert cloud.client.remote_autostart
|
||||
|
||||
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,
|
||||
}
|
||||
|
@ -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", [
|
||||
('non_existing_class', 'action.devices.types.SWITCH'),
|
||||
('switch', 'action.devices.types.SWITCH'),
|
||||
|
@ -843,6 +843,8 @@ async def test_lock_unlock_lock(hass):
|
||||
assert helpers.get_google_type(lock.DOMAIN, None) is not None
|
||||
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
|
||||
None)
|
||||
assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN,
|
||||
None)
|
||||
|
||||
trt = trait.LockUnlockTrait(hass,
|
||||
State('lock.front_door', lock.STATE_LOCKED),
|
||||
@ -922,6 +924,13 @@ async def test_lock_unlock_unlock(hass):
|
||||
assert len(calls) == 1
|
||||
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):
|
||||
"""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 trait.OpenCloseTrait.supported(
|
||||
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, {
|
||||
ATTR_DEVICE_CLASS: device_class,
|
||||
|
Loading…
x
Reference in New Issue
Block a user