Google Assistant: Add support for open/close binary sensors (#22674)

* Google Assistant: Add support for binary sensors

* Update test
This commit is contained in:
Paulus Schoutsen 2019-04-03 10:20:56 -07:00 committed by GitHub
parent 3872ac9bf9
commit 14da2fd8c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 207 additions and 73 deletions

View file

@ -15,30 +15,100 @@ DOMAIN = 'binary_sensor'
SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + '.{}'
# On means low, Off means normal
DEVICE_CLASS_BATTERY = 'battery'
# On means cold, Off means normal
DEVICE_CLASS_COLD = 'cold'
# On means connected, Off means disconnected
DEVICE_CLASS_CONNECTIVITY = 'connectivity'
# On means open, Off means closed
DEVICE_CLASS_DOOR = 'door'
# On means open, Off means closed
DEVICE_CLASS_GARAGE_DOOR = 'garage_door'
# On means gas detected, Off means no gas (clear)
DEVICE_CLASS_GAS = 'gas'
# On means hot, Off means normal
DEVICE_CLASS_HEAT = 'heat'
# On means light detected, Off means no light
DEVICE_CLASS_LIGHT = 'light'
# On means open (unlocked), Off means closed (locked)
DEVICE_CLASS_LOCK = 'lock'
# On means wet, Off means dry
DEVICE_CLASS_MOISTURE = 'moisture'
# On means motion detected, Off means no motion (clear)
DEVICE_CLASS_MOTION = 'motion'
# On means moving, Off means not moving (stopped)
DEVICE_CLASS_MOVING = 'moving'
# On means occupied, Off means not occupied (clear)
DEVICE_CLASS_OCCUPANCY = 'occupancy'
# On means open, Off means closed
DEVICE_CLASS_OPENING = 'opening'
# On means plugged in, Off means unplugged
DEVICE_CLASS_PLUG = 'plug'
# On means power detected, Off means no power
DEVICE_CLASS_POWER = 'power'
# On means home, Off means away
DEVICE_CLASS_PRESENCE = 'presence'
# On means problem detected, Off means no problem (OK)
DEVICE_CLASS_PROBLEM = 'problem'
# On means unsafe, Off means safe
DEVICE_CLASS_SAFETY = 'safety'
# On means smoke detected, Off means no smoke (clear)
DEVICE_CLASS_SMOKE = 'smoke'
# On means sound detected, Off means no sound (clear)
DEVICE_CLASS_SOUND = 'sound'
# On means vibration detected, Off means no vibration
DEVICE_CLASS_VIBRATION = 'vibration'
# On means open, Off means closed
DEVICE_CLASS_WINDOW = 'window'
DEVICE_CLASSES = [
'battery', # On means low, Off means normal
'cold', # On means cold, Off means normal
'connectivity', # On means connected, Off means disconnected
'door', # On means open, Off means closed
'garage_door', # On means open, Off means closed
'gas', # On means gas detected, Off means no gas (clear)
'heat', # On means hot, Off means normal
'light', # On means light detected, Off means no light
'lock', # On means open (unlocked), Off means closed (locked)
'moisture', # On means wet, Off means dry
'motion', # On means motion detected, Off means no motion (clear)
'moving', # On means moving, Off means not moving (stopped)
'occupancy', # On means occupied, Off means not occupied (clear)
'opening', # On means open, Off means closed
'plug', # On means plugged in, Off means unplugged
'power', # On means power detected, Off means no power
'presence', # On means home, Off means away
'problem', # On means problem detected, Off means no problem (OK)
'safety', # On means unsafe, Off means safe
'smoke', # On means smoke detected, Off means no smoke (clear)
'sound', # On means sound detected, Off means no sound (clear)
'vibration', # On means vibration detected, Off means no vibration
'window', # On means open, Off means closed
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_COLD,
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_DOOR,
DEVICE_CLASS_GARAGE_DOOR,
DEVICE_CLASS_GAS,
DEVICE_CLASS_HEAT,
DEVICE_CLASS_LIGHT,
DEVICE_CLASS_LOCK,
DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_MOVING,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PLUG,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESENCE,
DEVICE_CLASS_PROBLEM,
DEVICE_CLASS_SAFETY,
DEVICE_CLASS_SMOKE,
DEVICE_CLASS_SOUND,
DEVICE_CLASS_VIBRATION,
DEVICE_CLASS_WINDOW,
]
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))

View file

@ -9,7 +9,7 @@ from homeassistant.util.decorator import Registry
from homeassistant.core import callback
from homeassistant.const import (
CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE,
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_DEVICE_CLASS,
)
from homeassistant.components import (
camera,
@ -92,10 +92,11 @@ class _GoogleEntity:
state = self.state
domain = state.domain
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
self._traits = [Trait(self.hass, state, self.config)
for Trait in trait.TRAITS
if Trait.supported(domain, features)]
if Trait.supported(domain, features, device_class)]
return self._traits
async def sync_serialize(self):

View file

@ -2,6 +2,7 @@
import logging
from homeassistant.components import (
binary_sensor,
camera,
cover,
group,
@ -127,7 +128,7 @@ class BrightnessTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain == light.DOMAIN:
return features & light.SUPPORT_BRIGHTNESS
@ -193,7 +194,7 @@ class CameraStreamTrait(_Trait):
stream_info = None
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain == camera.DOMAIN:
return features & camera.SUPPORT_STREAM
@ -236,7 +237,7 @@ class OnOffTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain in (
group.DOMAIN,
@ -285,7 +286,7 @@ class ColorSpectrumTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain != light.DOMAIN:
return False
@ -341,7 +342,7 @@ class ColorTemperatureTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain != light.DOMAIN:
return False
@ -414,7 +415,7 @@ class SceneTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain in (scene.DOMAIN, script.DOMAIN)
@ -450,7 +451,7 @@ class DockTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain == vacuum.DOMAIN
@ -484,7 +485,7 @@ class StartStopTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain == vacuum.DOMAIN
@ -554,7 +555,7 @@ class TemperatureSettingTrait(_Trait):
google_to_hass = {value: key for key, value in hass_to_google.items()}
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain != climate.DOMAIN:
return False
@ -739,7 +740,7 @@ class LockUnlockTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain == lock.DOMAIN
@ -790,7 +791,7 @@ class FanSpeedTrait(_Trait):
}
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain != fan.DOMAIN:
return False
@ -941,7 +942,7 @@ class ModesTrait(_Trait):
}
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
if domain != media_player.DOMAIN:
return False
@ -1042,13 +1043,25 @@ class OpenCloseTrait(_Trait):
]
@staticmethod
def supported(domain, features):
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain == cover.DOMAIN
if domain == cover.DOMAIN:
return True
return domain == binary_sensor.DOMAIN and device_class in (
binary_sensor.DEVICE_CLASS_DOOR,
binary_sensor.DEVICE_CLASS_GARAGE_DOOR,
binary_sensor.DEVICE_CLASS_LOCK,
binary_sensor.DEVICE_CLASS_OPENING,
binary_sensor.DEVICE_CLASS_WINDOW,
)
def sync_attributes(self):
"""Return opening direction."""
return {}
attrs = {}
if self.state.domain == binary_sensor.DOMAIN:
attrs['queryOnlyOpenClose'] = True
return attrs
def query_attributes(self):
"""Return state query attributes."""
@ -1073,6 +1086,12 @@ class OpenCloseTrait(_Trait):
else:
response['openPercent'] = 0
elif domain == binary_sensor.DOMAIN:
if self.state.state == STATE_ON:
response['openPercent'] = 100
else:
response['openPercent'] = 0
return response
async def execute(self, command, data, params):

View file

@ -4,6 +4,7 @@ from unittest.mock import patch, Mock
import pytest
from homeassistant.components import (
binary_sensor,
camera,
cover,
fan,
@ -22,7 +23,7 @@ from homeassistant.components.google_assistant import trait, helpers, const
from homeassistant.const import (
STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF,
TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
ATTR_ASSUMED_STATE)
ATTR_DEVICE_CLASS, ATTR_ASSUMED_STATE)
from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
from homeassistant.util import color
from tests.common import async_mock_service, mock_coro
@ -49,7 +50,7 @@ UNSAFE_CONFIG = helpers.Config(
async def test_brightness_light(hass):
"""Test brightness trait support for light domain."""
assert trait.BrightnessTrait.supported(light.DOMAIN,
light.SUPPORT_BRIGHTNESS)
light.SUPPORT_BRIGHTNESS, None)
trt = trait.BrightnessTrait(hass, State('light.bla', light.STATE_ON, {
light.ATTR_BRIGHTNESS: 243
@ -87,7 +88,8 @@ async def test_brightness_light(hass):
async def test_brightness_media_player(hass):
"""Test brightness trait support for media player domain."""
assert trait.BrightnessTrait.supported(media_player.DOMAIN,
media_player.SUPPORT_VOLUME_SET)
media_player.SUPPORT_VOLUME_SET,
None)
trt = trait.BrightnessTrait(hass, State(
'media_player.bla', media_player.STATE_PLAYING, {
@ -116,7 +118,7 @@ async def test_camera_stream(hass):
"""Test camera stream trait support for camera domain."""
hass.config.api = Mock(base_url='http://1.1.1.1:8123')
assert trait.CameraStreamTrait.supported(camera.DOMAIN,
camera.SUPPORT_STREAM)
camera.SUPPORT_STREAM, None)
trt = trait.CameraStreamTrait(
hass, State('camera.bla', camera.STATE_IDLE, {}), BASIC_CONFIG
@ -143,7 +145,7 @@ async def test_camera_stream(hass):
async def test_onoff_group(hass):
"""Test OnOff trait support for group domain."""
assert trait.OnOffTrait.supported(group.DOMAIN, 0)
assert trait.OnOffTrait.supported(group.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG)
@ -181,7 +183,7 @@ async def test_onoff_group(hass):
async def test_onoff_input_boolean(hass):
"""Test OnOff trait support for input_boolean domain."""
assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0)
assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON),
BASIC_CONFIG)
@ -221,7 +223,7 @@ async def test_onoff_input_boolean(hass):
async def test_onoff_switch(hass):
"""Test OnOff trait support for switch domain."""
assert trait.OnOffTrait.supported(switch.DOMAIN, 0)
assert trait.OnOffTrait.supported(switch.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON),
BASIC_CONFIG)
@ -260,7 +262,7 @@ async def test_onoff_switch(hass):
async def test_onoff_fan(hass):
"""Test OnOff trait support for fan domain."""
assert trait.OnOffTrait.supported(fan.DOMAIN, 0)
assert trait.OnOffTrait.supported(fan.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG)
@ -296,7 +298,7 @@ async def test_onoff_fan(hass):
async def test_onoff_light(hass):
"""Test OnOff trait support for light domain."""
assert trait.OnOffTrait.supported(light.DOMAIN, 0)
assert trait.OnOffTrait.supported(light.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG)
@ -334,7 +336,7 @@ async def test_onoff_light(hass):
async def test_onoff_media_player(hass):
"""Test OnOff trait support for media_player domain."""
assert trait.OnOffTrait.supported(media_player.DOMAIN, 0)
assert trait.OnOffTrait.supported(media_player.DOMAIN, 0, None)
trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON),
BASIC_CONFIG)
@ -376,12 +378,12 @@ async def test_onoff_media_player(hass):
async def test_onoff_climate(hass):
"""Test OnOff trait not supported for climate domain."""
assert not trait.OnOffTrait.supported(
climate.DOMAIN, climate.SUPPORT_ON_OFF)
climate.DOMAIN, climate.SUPPORT_ON_OFF, None)
async def test_dock_vacuum(hass):
"""Test dock trait support for vacuum domain."""
assert trait.DockTrait.supported(vacuum.DOMAIN, 0)
assert trait.DockTrait.supported(vacuum.DOMAIN, 0, None)
trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE),
BASIC_CONFIG)
@ -404,7 +406,7 @@ async def test_dock_vacuum(hass):
async def test_startstop_vacuum(hass):
"""Test startStop trait support for vacuum domain."""
assert trait.StartStopTrait.supported(vacuum.DOMAIN, 0)
assert trait.StartStopTrait.supported(vacuum.DOMAIN, 0, None)
trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, {
ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE,
@ -452,9 +454,9 @@ async def test_startstop_vacuum(hass):
async def test_color_spectrum_light(hass):
"""Test ColorSpectrum trait support for light domain."""
assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0)
assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0, None)
assert trait.ColorSpectrumTrait.supported(light.DOMAIN,
light.SUPPORT_COLOR)
light.SUPPORT_COLOR, None)
trt = trait.ColorSpectrumTrait(hass, State('light.bla', STATE_ON, {
light.ATTR_HS_COLOR: (0, 94),
@ -496,9 +498,10 @@ async def test_color_spectrum_light(hass):
async def test_color_temperature_light(hass):
"""Test ColorTemperature trait support for light domain."""
assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0)
assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0, None)
assert trait.ColorTemperatureTrait.supported(light.DOMAIN,
light.SUPPORT_COLOR_TEMP)
light.SUPPORT_COLOR_TEMP,
None)
trt = trait.ColorTemperatureTrait(hass, State('light.bla', STATE_ON, {
light.ATTR_MIN_MIREDS: 200,
@ -552,9 +555,10 @@ async def test_color_temperature_light(hass):
async def test_color_temperature_light_bad_temp(hass):
"""Test ColorTemperature trait support for light domain."""
assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0)
assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0, None)
assert trait.ColorTemperatureTrait.supported(light.DOMAIN,
light.SUPPORT_COLOR_TEMP)
light.SUPPORT_COLOR_TEMP,
None)
trt = trait.ColorTemperatureTrait(hass, State('light.bla', STATE_ON, {
light.ATTR_MIN_MIREDS: 200,
@ -568,7 +572,7 @@ async def test_color_temperature_light_bad_temp(hass):
async def test_scene_scene(hass):
"""Test Scene trait support for scene domain."""
assert trait.SceneTrait.supported(scene.DOMAIN, 0)
assert trait.SceneTrait.supported(scene.DOMAIN, 0, None)
trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG)
assert trt.sync_attributes() == {}
@ -585,7 +589,7 @@ async def test_scene_scene(hass):
async def test_scene_script(hass):
"""Test Scene trait support for script domain."""
assert trait.SceneTrait.supported(script.DOMAIN, 0)
assert trait.SceneTrait.supported(script.DOMAIN, 0, None)
trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG)
assert trt.sync_attributes() == {}
@ -606,9 +610,9 @@ async def test_scene_script(hass):
async def test_temperature_setting_climate_onoff(hass):
"""Test TemperatureSetting trait support for climate domain - range."""
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0)
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
assert trait.TemperatureSettingTrait.supported(
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE)
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
hass.config.units.temperature_unit = TEMP_FAHRENHEIT
@ -650,9 +654,9 @@ async def test_temperature_setting_climate_onoff(hass):
async def test_temperature_setting_climate_range(hass):
"""Test TemperatureSetting trait support for climate domain - range."""
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0)
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
assert trait.TemperatureSettingTrait.supported(
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE)
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
hass.config.units.temperature_unit = TEMP_FAHRENHEIT
@ -725,9 +729,9 @@ async def test_temperature_setting_climate_range(hass):
async def test_temperature_setting_climate_setpoint(hass):
"""Test TemperatureSetting trait support for climate domain - setpoint."""
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0)
assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
assert trait.TemperatureSettingTrait.supported(
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE)
climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
hass.config.units.temperature_unit = TEMP_CELSIUS
@ -825,7 +829,8 @@ async def test_temperature_setting_climate_setpoint_auto(hass):
async def test_lock_unlock_lock(hass):
"""Test LockUnlock trait locking support for lock domain."""
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN)
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
None)
trt = trait.LockUnlockTrait(hass,
State('lock.front_door', lock.STATE_UNLOCKED),
@ -850,7 +855,8 @@ async def test_lock_unlock_lock(hass):
async def test_lock_unlock_unlock(hass):
"""Test LockUnlock trait unlocking support for lock domain."""
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN)
assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
None)
trt = trait.LockUnlockTrait(hass,
State('lock.front_door', lock.STATE_LOCKED),
@ -887,7 +893,8 @@ async def test_lock_unlock_unlock(hass):
async def test_fan_speed(hass):
"""Test FanSpeed trait speed control support for fan domain."""
assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED)
assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED,
None)
trt = trait.FanSpeedTrait(
hass, State(
@ -970,7 +977,7 @@ async def test_fan_speed(hass):
async def test_modes(hass):
"""Test Mode trait."""
assert trait.ModesTrait.supported(
media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE)
media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE, None)
trt = trait.ModesTrait(
hass, State(
@ -1056,9 +1063,9 @@ async def test_modes(hass):
async def test_openclose_cover(hass):
"""Test cover trait."""
"""Test OpenClose trait support for cover domain."""
assert trait.OpenCloseTrait.supported(cover.DOMAIN,
cover.SUPPORT_SET_POSITION)
cover.SUPPORT_SET_POSITION, None)
# No position
trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
@ -1098,3 +1105,40 @@ async def test_openclose_cover(hass):
ATTR_ENTITY_ID: 'cover.bla',
cover.ATTR_POSITION: 50
}
@pytest.mark.parametrize('device_class', (
binary_sensor.DEVICE_CLASS_DOOR,
binary_sensor.DEVICE_CLASS_GARAGE_DOOR,
binary_sensor.DEVICE_CLASS_LOCK,
binary_sensor.DEVICE_CLASS_OPENING,
binary_sensor.DEVICE_CLASS_WINDOW,
))
async def test_openclose_binary_sensor(hass, device_class):
"""Test OpenClose trait support for binary_sensor domain."""
assert trait.OpenCloseTrait.supported(binary_sensor.DOMAIN,
0, device_class)
trt = trait.OpenCloseTrait(hass, State('binary_sensor.test', STATE_ON, {
ATTR_DEVICE_CLASS: device_class,
}), BASIC_CONFIG)
assert trt.sync_attributes() == {
'queryOnlyOpenClose': True,
}
assert trt.query_attributes() == {
'openPercent': 100
}
trt = trait.OpenCloseTrait(hass, State('binary_sensor.test', STATE_OFF, {
ATTR_DEVICE_CLASS: device_class,
}), BASIC_CONFIG)
assert trt.sync_attributes() == {
'queryOnlyOpenClose': True,
}
assert trt.query_attributes() == {
'openPercent': 0
}