Add support for locks in google assistant component (#18233)
* Add support for locks in google assistant component This is supported by the smarthome API, but there is no documentation for it. This work is based on an article I found with screenshots of documentation that was erroneously uploaded: https://www.androidpolice.com/2018/01/17/google-assistant-home-can-now-natively-control-smart-locks-august-vivint-first-supported/ Google Assistant now supports unlocking certain locks - Nest and August come to mind - via this API, and this commit allows Home Assistant to do so as well. Notably, I've added a config option `allow_unlock` that controls whether we actually honor requests to unlock a lock via the google assistant. It defaults to false. Additionally, we add the functionNotSupported error, which makes a little more sense when we're unable to execute the desired state transition. https://developers.google.com/actions/reference/smarthome/errors-exceptions#exception_list * Fix linter warnings * Ensure that certain groups are never exposed to cloud entities For example, the group.all_locks entity - we should probably never expose this to third party cloud integrations. It's risky. This is not configurable, but can be extended by adding to the cloud.const.NEVER_EXPOSED_ENTITIES array. It's implemented in a modestly hacky fashion, because we determine whether or not a entity should be excluded/included in several ways. Notably, we define this array in the top level const.py, to avoid circular import problems between the cloud/alexa components.
This commit is contained in:
parent
ddee5f8b86
commit
2bf2214d51
14 changed files with 283 additions and 43 deletions
|
@ -16,9 +16,9 @@ from homeassistant.components import (
|
|||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, STATE_UNLOCKED,
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT)
|
||||
|
@ -1194,6 +1194,11 @@ async def async_api_discovery(hass, config, directive, context):
|
|||
discovery_endpoints = []
|
||||
|
||||
for entity in hass.states.async_all():
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
_LOGGER.debug("Not exposing %s because it is never exposed",
|
||||
entity.entity_id)
|
||||
continue
|
||||
|
||||
if not config.should_expose(entity.entity_id):
|
||||
_LOGGER.debug("Not exposing %s because filtered by config",
|
||||
entity.entity_id)
|
||||
|
|
|
@ -12,7 +12,8 @@ import os
|
|||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME)
|
||||
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
|
||||
CONF_MODE, CONF_NAME)
|
||||
from homeassistant.helpers import entityfilter, config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
|
@ -68,7 +69,9 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
|||
})
|
||||
|
||||
GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
||||
vol.Optional(ga_c.CONF_ALLOW_UNLOCK,
|
||||
default=ga_c.DEFAULT_ALLOW_UNLOCK): cv.boolean
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
|
@ -184,12 +187,16 @@ class Cloud:
|
|||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
return conf['filter'](entity.entity_id)
|
||||
|
||||
self._gactions_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
agent_user_id=self.claims['cognito:username'],
|
||||
entity_config=conf.get(CONF_ENTITY_CONFIG),
|
||||
allow_unlock=conf.get(ga_c.CONF_ALLOW_UNLOCK),
|
||||
)
|
||||
|
||||
return self._gactions_config
|
||||
|
|
|
@ -24,7 +24,8 @@ from .const import (
|
|||
DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT,
|
||||
CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY,
|
||||
SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG,
|
||||
CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT
|
||||
CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK,
|
||||
DEFAULT_ALLOW_UNLOCK
|
||||
)
|
||||
from .http import async_register_http
|
||||
|
||||
|
@ -48,7 +49,9 @@ GOOGLE_ASSISTANT_SCHEMA = vol.Schema({
|
|||
vol.Optional(CONF_EXPOSED_DOMAINS,
|
||||
default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list,
|
||||
vol.Optional(CONF_API_KEY): cv.string,
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA},
|
||||
vol.Optional(CONF_ALLOW_UNLOCK,
|
||||
default=DEFAULT_ALLOW_UNLOCK): cv.boolean
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
|
|
|
@ -11,12 +11,14 @@ CONF_PROJECT_ID = 'project_id'
|
|||
CONF_ALIASES = 'aliases'
|
||||
CONF_API_KEY = 'api_key'
|
||||
CONF_ROOM_HINT = 'room'
|
||||
CONF_ALLOW_UNLOCK = 'allow_unlock'
|
||||
|
||||
DEFAULT_EXPOSE_BY_DEFAULT = True
|
||||
DEFAULT_EXPOSED_DOMAINS = [
|
||||
'climate', 'cover', 'fan', 'group', 'input_boolean', 'light',
|
||||
'media_player', 'scene', 'script', 'switch', 'vacuum',
|
||||
'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock',
|
||||
]
|
||||
DEFAULT_ALLOW_UNLOCK = False
|
||||
CLIMATE_MODE_HEATCOOL = 'heatcool'
|
||||
CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL}
|
||||
|
||||
|
@ -27,6 +29,7 @@ TYPE_VACUUM = PREFIX_TYPES + 'VACUUM'
|
|||
TYPE_SCENE = PREFIX_TYPES + 'SCENE'
|
||||
TYPE_FAN = PREFIX_TYPES + 'FAN'
|
||||
TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT'
|
||||
TYPE_LOCK = PREFIX_TYPES + 'LOCK'
|
||||
|
||||
SERVICE_REQUEST_SYNC = 'request_sync'
|
||||
HOMEGRAPH_URL = 'https://homegraph.googleapis.com/'
|
||||
|
@ -40,3 +43,4 @@ ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange"
|
|||
ERR_NOT_SUPPORTED = "notSupported"
|
||||
ERR_PROTOCOL_ERROR = 'protocolError'
|
||||
ERR_UNKNOWN_ERROR = 'unknownError'
|
||||
ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported'
|
||||
|
|
|
@ -16,8 +16,10 @@ class SmartHomeError(Exception):
|
|||
class Config:
|
||||
"""Hold the configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, should_expose, agent_user_id, entity_config=None):
|
||||
def __init__(self, should_expose, agent_user_id, entity_config=None,
|
||||
allow_unlock=False):
|
||||
"""Initialize the configuration."""
|
||||
self.should_expose = should_expose
|
||||
self.agent_user_id = agent_user_id
|
||||
self.entity_config = entity_config or {}
|
||||
self.allow_unlock = allow_unlock
|
||||
|
|
|
@ -11,6 +11,7 @@ from aiohttp.web import Request, Response
|
|||
# Typing imports
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
|
||||
from .const import (
|
||||
GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||
|
@ -38,6 +39,9 @@ def async_register_http(hass, cfg):
|
|||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
explicit_expose = \
|
||||
entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE)
|
||||
|
||||
|
|
|
@ -7,7 +7,9 @@ from homeassistant.util.decorator import Registry
|
|||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES)
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE,
|
||||
ATTR_SUPPORTED_FEATURES
|
||||
)
|
||||
from homeassistant.components import (
|
||||
climate,
|
||||
cover,
|
||||
|
@ -15,6 +17,7 @@ from homeassistant.components import (
|
|||
group,
|
||||
input_boolean,
|
||||
light,
|
||||
lock,
|
||||
media_player,
|
||||
scene,
|
||||
script,
|
||||
|
@ -22,12 +25,13 @@ from homeassistant.components import (
|
|||
vacuum,
|
||||
)
|
||||
|
||||
|
||||
from . import trait
|
||||
from .const import (
|
||||
TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM,
|
||||
TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM,
|
||||
TYPE_THERMOSTAT, TYPE_FAN,
|
||||
CONF_ALIASES, CONF_ROOM_HINT,
|
||||
ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
|
||||
ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
|
||||
ERR_UNKNOWN_ERROR
|
||||
)
|
||||
from .helpers import SmartHomeError
|
||||
|
@ -42,6 +46,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
|
|||
group.DOMAIN: TYPE_SWITCH,
|
||||
input_boolean.DOMAIN: TYPE_SWITCH,
|
||||
light.DOMAIN: TYPE_LIGHT,
|
||||
lock.DOMAIN: TYPE_LOCK,
|
||||
media_player.DOMAIN: TYPE_SWITCH,
|
||||
scene.DOMAIN: TYPE_SCENE,
|
||||
script.DOMAIN: TYPE_SCENE,
|
||||
|
@ -80,7 +85,7 @@ class _GoogleEntity:
|
|||
domain = state.domain
|
||||
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return [Trait(self.hass, state) for Trait in trait.TRAITS
|
||||
return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS
|
||||
if Trait.supported(domain, features)]
|
||||
|
||||
@callback
|
||||
|
@ -168,7 +173,7 @@ class _GoogleEntity:
|
|||
|
||||
if not executed:
|
||||
raise SmartHomeError(
|
||||
ERR_NOT_SUPPORTED,
|
||||
ERR_FUNCTION_NOT_SUPPORTED,
|
||||
'Unable to execute {} for {}'.format(command,
|
||||
self.state.entity_id))
|
||||
|
||||
|
@ -232,6 +237,9 @@ async def async_devices_sync(hass, config, payload):
|
|||
"""
|
||||
devices = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
if not config.should_expose(state):
|
||||
continue
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ from homeassistant.components import (
|
|||
input_boolean,
|
||||
media_player,
|
||||
light,
|
||||
lock,
|
||||
scene,
|
||||
script,
|
||||
switch,
|
||||
|
@ -19,6 +20,7 @@ from homeassistant.const import (
|
|||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_LOCKED,
|
||||
STATE_OFF,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
|
@ -40,6 +42,7 @@ TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum'
|
|||
TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature'
|
||||
TRAIT_SCENE = PREFIX_TRAITS + 'Scene'
|
||||
TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting'
|
||||
TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock'
|
||||
|
||||
PREFIX_COMMANDS = 'action.devices.commands.'
|
||||
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
||||
|
@ -54,6 +57,7 @@ COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = (
|
|||
COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = (
|
||||
PREFIX_COMMANDS + 'ThermostatTemperatureSetRange')
|
||||
COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode'
|
||||
COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock'
|
||||
|
||||
|
||||
TRAITS = []
|
||||
|
@ -77,10 +81,11 @@ class _Trait:
|
|||
|
||||
commands = []
|
||||
|
||||
def __init__(self, hass, state):
|
||||
def __init__(self, hass, state, config):
|
||||
"""Initialize a trait for a state."""
|
||||
self.hass = hass
|
||||
self.state = state
|
||||
self.config = config
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return attributes for a sync request."""
|
||||
|
@ -628,3 +633,45 @@ class TemperatureSettingTrait(_Trait):
|
|||
climate.ATTR_OPERATION_MODE:
|
||||
self.google_to_hass[params['thermostatMode']],
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@register_trait
|
||||
class LockUnlockTrait(_Trait):
|
||||
"""Trait to lock or unlock a lock.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/lockunlock
|
||||
"""
|
||||
|
||||
name = TRAIT_LOCKUNLOCK
|
||||
commands = [
|
||||
COMMAND_LOCKUNLOCK
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
return domain == lock.DOMAIN
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return LockUnlock attributes for a sync request."""
|
||||
return {}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return LockUnlock query attributes."""
|
||||
return {'isLocked': self.state.state == STATE_LOCKED}
|
||||
|
||||
def can_execute(self, command, params):
|
||||
"""Test if command can be executed."""
|
||||
allowed_unlock = not params['lock'] and self.config.allow_unlock
|
||||
return params['lock'] or allowed_unlock
|
||||
|
||||
async def execute(self, command, params):
|
||||
"""Execute an LockUnlock command."""
|
||||
if params['lock']:
|
||||
service = lock.SERVICE_LOCK
|
||||
else:
|
||||
service = lock.SERVICE_UNLOCK
|
||||
|
||||
await self.hass.services.async_call(lock.DOMAIN, service, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=True)
|
||||
|
|
|
@ -449,3 +449,7 @@ WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
|||
PRECISION_WHOLE = 1
|
||||
PRECISION_HALVES = 0.5
|
||||
PRECISION_TENTHS = 0.1
|
||||
|
||||
# Static list of entities that will never be exposed to
|
||||
# cloud, alexa, or google_home components
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES = ['group.all_locks']
|
||||
|
|
|
@ -987,6 +987,32 @@ async def test_include_filters(hass):
|
|||
assert len(msg['payload']['endpoints']) == 3
|
||||
|
||||
|
||||
async def test_never_exposed_entities(hass):
|
||||
"""Test never exposed locks do not get discovered."""
|
||||
request = get_new_request('Alexa.Discovery', 'Discover')
|
||||
|
||||
# setup test devices
|
||||
hass.states.async_set(
|
||||
'group.all_locks', 'on', {'friendly_name': "Blocked locks"})
|
||||
|
||||
hass.states.async_set(
|
||||
'group.allow', 'off', {'friendly_name': "Allowed group"})
|
||||
|
||||
config = smart_home.Config(should_expose=entityfilter.generate_filter(
|
||||
include_domains=['group'],
|
||||
include_entities=[],
|
||||
exclude_domains=[],
|
||||
exclude_entities=[],
|
||||
))
|
||||
|
||||
msg = await smart_home.async_handle_message(hass, config, request)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
msg = msg['event']
|
||||
|
||||
assert len(msg['payload']['endpoints']) == 1
|
||||
|
||||
|
||||
async def test_api_entity_not_exists(hass):
|
||||
"""Test api turn on process without entity."""
|
||||
request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test')
|
||||
|
|
|
@ -326,6 +326,8 @@ def test_handler_google_actions(hass):
|
|||
'switch.test', 'on', {'friendly_name': "Test switch"})
|
||||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
hass.states.async_set(
|
||||
'group.all_locks', 'on', {'friendly_name': "Evil locks"})
|
||||
|
||||
with patch('homeassistant.components.cloud.Cloud.async_start',
|
||||
return_value=mock_coro()):
|
||||
|
|
|
@ -230,4 +230,28 @@ DEMO_DEVICES = [{
|
|||
'traits': ['action.devices.traits.TemperatureSetting'],
|
||||
'type': 'action.devices.types.THERMOSTAT',
|
||||
'willReportState': False
|
||||
}, {
|
||||
'id': 'lock.front_door',
|
||||
'name': {
|
||||
'name': 'Front Door'
|
||||
},
|
||||
'traits': ['action.devices.traits.LockUnlock'],
|
||||
'type': 'action.devices.types.LOCK',
|
||||
'willReportState': False
|
||||
}, {
|
||||
'id': 'lock.kitchen_door',
|
||||
'name': {
|
||||
'name': 'Kitchen Door'
|
||||
},
|
||||
'traits': ['action.devices.traits.LockUnlock'],
|
||||
'type': 'action.devices.types.LOCK',
|
||||
'willReportState': False
|
||||
}, {
|
||||
'id': 'lock.openable_lock',
|
||||
'name': {
|
||||
'name': 'Openable Lock'
|
||||
},
|
||||
'traits': ['action.devices.traits.LockUnlock'],
|
||||
'type': 'action.devices.types.LOCK',
|
||||
'willReportState': False
|
||||
}]
|
||||
|
|
|
@ -8,7 +8,8 @@ import pytest
|
|||
|
||||
from homeassistant import core, const, setup
|
||||
from homeassistant.components import (
|
||||
fan, cover, light, switch, climate, async_setup, media_player)
|
||||
fan, cover, light, switch, climate, lock, async_setup, media_player)
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.components import google_assistant as ga
|
||||
|
||||
from . import DEMO_DEVICES
|
||||
|
@ -96,6 +97,13 @@ def hass_fixture(loop, hass):
|
|||
}]
|
||||
}))
|
||||
|
||||
loop.run_until_complete(
|
||||
setup.async_setup_component(hass, lock.DOMAIN, {
|
||||
'lock': [{
|
||||
'platform': 'demo'
|
||||
}]
|
||||
}))
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
|
@ -116,6 +124,9 @@ def test_sync_request(hass_fixture, assistant_client, auth_header):
|
|||
sorted([dev['id'] for dev in devices])
|
||||
== sorted([dev['id'] for dev in DEMO_DEVICES]))
|
||||
|
||||
for dev in devices:
|
||||
assert dev['id'] not in CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
|
||||
for dev, demo in zip(
|
||||
sorted(devices, key=lambda d: d['id']),
|
||||
sorted(DEMO_DEVICES, key=lambda d: d['id'])):
|
||||
|
|
|
@ -11,6 +11,7 @@ from homeassistant.components import (
|
|||
fan,
|
||||
input_boolean,
|
||||
light,
|
||||
lock,
|
||||
media_player,
|
||||
scene,
|
||||
script,
|
||||
|
@ -23,6 +24,17 @@ from homeassistant.util import color
|
|||
|
||||
from tests.common import async_mock_service
|
||||
|
||||
BASIC_CONFIG = helpers.Config(
|
||||
should_expose=lambda state: True,
|
||||
agent_user_id='test-agent',
|
||||
)
|
||||
|
||||
UNSAFE_CONFIG = helpers.Config(
|
||||
should_expose=lambda state: True,
|
||||
agent_user_id='test-agent',
|
||||
allow_unlock=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_brightness_light(hass):
|
||||
"""Test brightness trait support for light domain."""
|
||||
|
@ -31,7 +43,7 @@ async def test_brightness_light(hass):
|
|||
|
||||
trt = trait.BrightnessTrait(hass, State('light.bla', light.STATE_ON, {
|
||||
light.ATTR_BRIGHTNESS: 243
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
|
@ -57,7 +69,7 @@ async def test_brightness_cover(hass):
|
|||
|
||||
trt = trait.BrightnessTrait(hass, State('cover.bla', cover.STATE_OPEN, {
|
||||
cover.ATTR_CURRENT_POSITION: 75
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
|
@ -85,7 +97,7 @@ async def test_brightness_media_player(hass):
|
|||
trt = trait.BrightnessTrait(hass, State(
|
||||
'media_player.bla', media_player.STATE_PLAYING, {
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: .3
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
|
@ -109,7 +121,7 @@ async def test_onoff_group(hass):
|
|||
"""Test OnOff trait support for group domain."""
|
||||
assert trait.OnOffTrait.supported(group.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -117,7 +129,9 @@ async def test_onoff_group(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -145,7 +159,8 @@ async def test_onoff_input_boolean(hass):
|
|||
"""Test OnOff trait support for input_boolean domain."""
|
||||
assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -153,7 +168,9 @@ async def test_onoff_input_boolean(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -182,7 +199,8 @@ async def test_onoff_switch(hass):
|
|||
"""Test OnOff trait support for switch domain."""
|
||||
assert trait.OnOffTrait.supported(switch.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -190,7 +208,9 @@ async def test_onoff_switch(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -218,7 +238,7 @@ async def test_onoff_fan(hass):
|
|||
"""Test OnOff trait support for fan domain."""
|
||||
assert trait.OnOffTrait.supported(fan.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -226,7 +246,7 @@ async def test_onoff_fan(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF), BASIC_CONFIG)
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -254,7 +274,7 @@ async def test_onoff_light(hass):
|
|||
"""Test OnOff trait support for light domain."""
|
||||
assert trait.OnOffTrait.supported(light.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -262,7 +282,9 @@ async def test_onoff_light(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -290,7 +312,8 @@ async def test_onoff_cover(hass):
|
|||
"""Test OnOff trait support for cover domain."""
|
||||
assert trait.OnOffTrait.supported(cover.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN))
|
||||
trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -298,7 +321,9 @@ async def test_onoff_cover(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED))
|
||||
trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -327,7 +352,8 @@ async def test_onoff_media_player(hass):
|
|||
"""Test OnOff trait support for media_player domain."""
|
||||
assert trait.OnOffTrait.supported(media_player.DOMAIN, 0)
|
||||
|
||||
trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON))
|
||||
trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_on.sync_attributes() == {}
|
||||
|
||||
|
@ -335,7 +361,9 @@ async def test_onoff_media_player(hass):
|
|||
'on': True
|
||||
}
|
||||
|
||||
trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF))
|
||||
trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt_off.query_attributes() == {
|
||||
'on': False
|
||||
}
|
||||
|
@ -349,7 +377,9 @@ async def test_onoff_media_player(hass):
|
|||
ATTR_ENTITY_ID: 'media_player.bla',
|
||||
}
|
||||
|
||||
off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF)
|
||||
off_calls = async_mock_service(hass, media_player.DOMAIN,
|
||||
SERVICE_TURN_OFF)
|
||||
|
||||
await trt_on.execute(trait.COMMAND_ONOFF, {
|
||||
'on': False
|
||||
})
|
||||
|
@ -363,7 +393,8 @@ async def test_dock_vacuum(hass):
|
|||
"""Test dock trait support for vacuum domain."""
|
||||
assert trait.DockTrait.supported(vacuum.DOMAIN, 0)
|
||||
|
||||
trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE))
|
||||
trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
|
@ -386,7 +417,7 @@ async def test_startstop_vacuum(hass):
|
|||
|
||||
trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, {
|
||||
ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE,
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {'pausable': True}
|
||||
|
||||
|
@ -436,7 +467,7 @@ async def test_color_spectrum_light(hass):
|
|||
|
||||
trt = trait.ColorSpectrumTrait(hass, State('light.bla', STATE_ON, {
|
||||
light.ATTR_HS_COLOR: (0, 94),
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {
|
||||
'colorModel': 'rgb'
|
||||
|
@ -482,7 +513,7 @@ async def test_color_temperature_light(hass):
|
|||
light.ATTR_MIN_MIREDS: 200,
|
||||
light.ATTR_COLOR_TEMP: 300,
|
||||
light.ATTR_MAX_MIREDS: 500,
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {
|
||||
'temperatureMinK': 2000,
|
||||
|
@ -538,7 +569,7 @@ async def test_color_temperature_light_bad_temp(hass):
|
|||
light.ATTR_MIN_MIREDS: 200,
|
||||
light.ATTR_COLOR_TEMP: 0,
|
||||
light.ATTR_MAX_MIREDS: 500,
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
}
|
||||
|
@ -548,7 +579,7 @@ async def test_scene_scene(hass):
|
|||
"""Test Scene trait support for scene domain."""
|
||||
assert trait.SceneTrait.supported(scene.DOMAIN, 0)
|
||||
|
||||
trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE))
|
||||
trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG)
|
||||
assert trt.sync_attributes() == {}
|
||||
assert trt.query_attributes() == {}
|
||||
assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {})
|
||||
|
@ -565,7 +596,7 @@ async def test_scene_script(hass):
|
|||
"""Test Scene trait support for script domain."""
|
||||
assert trait.SceneTrait.supported(script.DOMAIN, 0)
|
||||
|
||||
trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF))
|
||||
trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG)
|
||||
assert trt.sync_attributes() == {}
|
||||
assert trt.query_attributes() == {}
|
||||
assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {})
|
||||
|
@ -605,7 +636,7 @@ async def test_temperature_setting_climate_range(hass):
|
|||
climate.ATTR_TARGET_TEMP_LOW: 65,
|
||||
climate.ATTR_MIN_TEMP: 50,
|
||||
climate.ATTR_MAX_TEMP: 80
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
assert trt.sync_attributes() == {
|
||||
'availableThermostatModes': 'off,cool,heat,heatcool',
|
||||
'thermostatTemperatureUnit': 'F',
|
||||
|
@ -672,7 +703,7 @@ async def test_temperature_setting_climate_setpoint(hass):
|
|||
climate.ATTR_MAX_TEMP: 30,
|
||||
climate.ATTR_TEMPERATURE: 18,
|
||||
climate.ATTR_CURRENT_TEMPERATURE: 20
|
||||
}))
|
||||
}), BASIC_CONFIG)
|
||||
assert trt.sync_attributes() == {
|
||||
'availableThermostatModes': 'off,cool',
|
||||
'thermostatTemperatureUnit': 'C',
|
||||
|
@ -702,3 +733,65 @@ async def test_temperature_setting_climate_setpoint(hass):
|
|||
ATTR_ENTITY_ID: 'climate.bla',
|
||||
climate.ATTR_TEMPERATURE: 19
|
||||
}
|
||||
|
||||
|
||||
async def test_lock_unlock_lock(hass):
|
||||
"""Test LockUnlock trait locking support for lock domain."""
|
||||
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN)
|
||||
|
||||
trt = trait.LockUnlockTrait(hass,
|
||||
State('lock.front_door', lock.STATE_UNLOCKED),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
'isLocked': False
|
||||
}
|
||||
|
||||
assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True})
|
||||
|
||||
calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK)
|
||||
await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': True})
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data == {
|
||||
ATTR_ENTITY_ID: 'lock.front_door'
|
||||
}
|
||||
|
||||
|
||||
async def test_lock_unlock_unlock(hass):
|
||||
"""Test LockUnlock trait unlocking support for lock domain."""
|
||||
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN)
|
||||
|
||||
trt = trait.LockUnlockTrait(hass,
|
||||
State('lock.front_door', lock.STATE_LOCKED),
|
||||
BASIC_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
'isLocked': True
|
||||
}
|
||||
|
||||
assert not trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False})
|
||||
|
||||
trt = trait.LockUnlockTrait(hass,
|
||||
State('lock.front_door', lock.STATE_LOCKED),
|
||||
UNSAFE_CONFIG)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
'isLocked': True
|
||||
}
|
||||
|
||||
assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False})
|
||||
|
||||
calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK)
|
||||
await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': False})
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data == {
|
||||
ATTR_ENTITY_ID: 'lock.front_door'
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue