From 2bf2214d5151178738898f286dab3648b96f1691 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Tue, 6 Nov 2018 03:39:10 -0600 Subject: [PATCH] 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. --- homeassistant/components/alexa/smart_home.py | 11 +- homeassistant/components/cloud/__init__.py | 11 +- .../components/google_assistant/__init__.py | 7 +- .../components/google_assistant/const.py | 6 +- .../components/google_assistant/helpers.py | 4 +- .../components/google_assistant/http.py | 4 + .../components/google_assistant/smart_home.py | 18 ++- .../components/google_assistant/trait.py | 49 +++++- homeassistant/const.py | 4 + tests/components/alexa/test_smart_home.py | 26 ++++ tests/components/cloud/test_iot.py | 2 + tests/components/google_assistant/__init__.py | 24 +++ .../google_assistant/test_google_assistant.py | 13 +- .../components/google_assistant/test_trait.py | 147 ++++++++++++++---- 14 files changed, 283 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 6b747689057..80e584a4a75 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -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) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index bc486eb7ead..d9ee2a62b84 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -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 diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 8d4ac9f01c9..f444974bc8d 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -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({ diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2f54ee33f77..aca960f9c0a 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -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' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ef6ae109eb5..e71756d9fee 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -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 diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 65af7b932b0..a6b4633e762 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -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) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 633e6258c03..bab63bdb7ae 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -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 diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 00a01f262a9..ce13818d9de 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -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) diff --git a/homeassistant/const.py b/homeassistant/const.py index ffbba575a14..50a27f9d9c8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -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'] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 186a35c19ec..4ea06b57a38 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -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') diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 07ec1851fbe..d0b145c1b67 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -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()): diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 273a7e86505..5fd00abc411 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -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 }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2ebfa5cc9ed..047fad3574c 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -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'])): diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a347b6c6fc0..ff3ce65ee27 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -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' + }