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

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