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:
Andrew Hayworth 2018-11-06 03:39:10 -06:00 committed by Paulus Schoutsen
parent ddee5f8b86
commit 2bf2214d51
14 changed files with 283 additions and 43 deletions

View file

@ -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)

View file

@ -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

View file

@ -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({

View file

@ -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'

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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']

View file

@ -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')

View file

@ -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()):

View file

@ -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
}]

View file

@ -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'])):

View file

@ -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'
}