mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +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 . 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,
|
||||||
|
@ -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'
|
||||||
|
@ -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']))
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
}
|
||||||
|
@ -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'),
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user