Add support for exposing light effects via Google Assistant (#38575)

* Don't set SUPPORT_EFFECT on DemoLight if there are no effects

This requires an update to the group test - previously the other lights
instantiated by the DemoLight component had nothing in ATTR_EFFECT_LIST, but
still had SUPPORT_EFFECT set. This appears to have resulted in the light
group test code setting an effect on the group and expecting it to apply to
all lights, but given that two of the bulbs didn't actually support any
effects (due to the empty ATTR_EFFECT_LIST) this seems like a broken
assumption and updating the test to verify only the bulb that supports
effects has had one applied seems reasonable.

* Add support for exposing light effects via Google Assistant

The LightEffects trait only supports a fixed (and small) list of lighting
effects, but we can expose them via the Modes trait - this requires saying
"Set (foo) effect to (bar)" which is a little clumsy, but at least makes it
possible.
This commit is contained in:
Matthew Garrett 2020-08-08 14:28:04 -07:00 committed by GitHub
parent e304792f3a
commit 34e2a1825b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 208 additions and 13 deletions

View file

@ -24,11 +24,7 @@ LIGHT_EFFECT_LIST = ["rainbow", "none"]
LIGHT_TEMPS = [240, 380] LIGHT_TEMPS = [240, 380]
SUPPORT_DEMO = ( SUPPORT_DEMO = (
SUPPORT_BRIGHTNESS SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR | SUPPORT_WHITE_VALUE
| SUPPORT_COLOR_TEMP
| SUPPORT_EFFECT
| SUPPORT_COLOR
| SUPPORT_WHITE_VALUE
) )
@ -81,10 +77,13 @@ class DemoLight(LightEntity):
self._ct = ct or random.choice(LIGHT_TEMPS) self._ct = ct or random.choice(LIGHT_TEMPS)
self._brightness = brightness self._brightness = brightness
self._white = white self._white = white
self._features = SUPPORT_DEMO
self._effect_list = effect_list self._effect_list = effect_list
self._effect = effect self._effect = effect
self._available = True self._available = True
self._color_mode = "ct" if ct is not None and hs_color is None else "hs" self._color_mode = "ct" if ct is not None and hs_color is None else "hs"
if self._effect_list is not None:
self._features |= SUPPORT_EFFECT
@property @property
def device_info(self): def device_info(self):
@ -161,7 +160,7 @@ class DemoLight(LightEntity):
@property @property
def supported_features(self) -> int: def supported_features(self) -> int:
"""Flag supported features.""" """Flag supported features."""
return SUPPORT_DEMO return self._features
async def async_turn_on(self, **kwargs) -> None: async def async_turn_on(self, **kwargs) -> None:
"""Turn the light on.""" """Turn the light on."""

View file

@ -1280,6 +1280,9 @@ class ModesTrait(_Trait):
if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES:
return True return True
if domain == light.DOMAIN and features & light.SUPPORT_EFFECT:
return True
if domain != media_player.DOMAIN: if domain != media_player.DOMAIN:
return False return False
@ -1317,6 +1320,7 @@ class ModesTrait(_Trait):
(media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"),
(input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"),
(humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"),
(light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"),
): ):
if self.state.domain != domain: if self.state.domain != domain:
continue continue
@ -1347,6 +1351,9 @@ class ModesTrait(_Trait):
elif self.state.domain == humidifier.DOMAIN: elif self.state.domain == humidifier.DOMAIN:
if humidifier.ATTR_MODE in attrs: if humidifier.ATTR_MODE in attrs:
mode_settings["mode"] = attrs.get(humidifier.ATTR_MODE) mode_settings["mode"] = attrs.get(humidifier.ATTR_MODE)
elif self.state.domain == light.DOMAIN:
if light.ATTR_EFFECT in attrs:
mode_settings["effect"] = attrs.get(light.ATTR_EFFECT)
if mode_settings: if mode_settings:
response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN) response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN)
@ -1386,6 +1393,20 @@ class ModesTrait(_Trait):
) )
return return
if self.state.domain == light.DOMAIN:
requested_effect = settings["effect"]
await self.hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: self.state.entity_id,
light.ATTR_EFFECT: requested_effect,
},
blocking=True,
context=data.context,
)
return
if self.state.domain != media_player.DOMAIN: if self.state.domain != media_player.DOMAIN:
_LOGGER.info( _LOGGER.info(
"Received an Options command for unrecognised domain %s", "Received an Options command for unrecognised domain %s",

View file

@ -128,6 +128,7 @@ DEMO_DEVICES = [
"action.devices.traits.OnOff", "action.devices.traits.OnOff",
"action.devices.traits.Brightness", "action.devices.traits.Brightness",
"action.devices.traits.ColorSetting", "action.devices.traits.ColorSetting",
"action.devices.traits.Modes",
], ],
"type": "action.devices.types.LIGHT", "type": "action.devices.types.LIGHT",
"willReportState": False, "willReportState": False,

View file

@ -9,7 +9,7 @@ from homeassistant.components.climate.const import (
) )
from homeassistant.components.demo.binary_sensor import DemoBinarySensor from homeassistant.components.demo.binary_sensor import DemoBinarySensor
from homeassistant.components.demo.cover import DemoCover from homeassistant.components.demo.cover import DemoCover
from homeassistant.components.demo.light import DemoLight from homeassistant.components.demo.light import LIGHT_EFFECT_LIST, DemoLight
from homeassistant.components.demo.media_player import AbstractDemoPlayer from homeassistant.components.demo.media_player import AbstractDemoPlayer
from homeassistant.components.demo.switch import DemoSwitch from homeassistant.components.demo.switch import DemoSwitch
from homeassistant.components.google_assistant import ( from homeassistant.components.google_assistant import (
@ -48,7 +48,14 @@ def registries(hass):
async def test_sync_message(hass): async def test_sync_message(hass):
"""Test a sync message.""" """Test a sync message."""
light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75)) light = DemoLight(
None,
"Demo Light",
state=False,
hs_color=(180, 75),
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
)
light.hass = hass light.hass = hass
light.entity_id = "light.demo_light" light.entity_id = "light.demo_light"
await light.async_update_ha_state() await light.async_update_ha_state()
@ -95,10 +102,37 @@ async def test_sync_message(hass):
trait.TRAIT_BRIGHTNESS, trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF, trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING, trait.TRAIT_COLOR_SETTING,
trait.TRAIT_MODES,
], ],
"type": const.TYPE_LIGHT, "type": const.TYPE_LIGHT,
"willReportState": False, "willReportState": False,
"attributes": { "attributes": {
"availableModes": [
{
"name": "effect",
"name_values": [
{"lang": "en", "name_synonym": ["effect"]}
],
"ordered": False,
"settings": [
{
"setting_name": "rainbow",
"setting_values": [
{
"lang": "en",
"setting_synonym": ["rainbow"],
}
],
},
{
"setting_name": "none",
"setting_values": [
{"lang": "en", "setting_synonym": ["none"]}
],
},
],
}
],
"colorModel": "hsv", "colorModel": "hsv",
"colorTemperatureRange": { "colorTemperatureRange": {
"temperatureMinK": 2000, "temperatureMinK": 2000,
@ -132,7 +166,14 @@ async def test_sync_in_area(hass, registries):
"light", "test", "1235", suggested_object_id="demo_light", device_id=device.id "light", "test", "1235", suggested_object_id="demo_light", device_id=device.id
) )
light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75)) light = DemoLight(
None,
"Demo Light",
state=False,
hs_color=(180, 75),
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
)
light.hass = hass light.hass = hass
light.entity_id = entity.entity_id light.entity_id = entity.entity_id
await light.async_update_ha_state() await light.async_update_ha_state()
@ -162,10 +203,37 @@ async def test_sync_in_area(hass, registries):
trait.TRAIT_BRIGHTNESS, trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF, trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING, trait.TRAIT_COLOR_SETTING,
trait.TRAIT_MODES,
], ],
"type": const.TYPE_LIGHT, "type": const.TYPE_LIGHT,
"willReportState": False, "willReportState": False,
"attributes": { "attributes": {
"availableModes": [
{
"name": "effect",
"name_values": [
{"lang": "en", "name_synonym": ["effect"]}
],
"ordered": False,
"settings": [
{
"setting_name": "rainbow",
"setting_values": [
{
"lang": "en",
"setting_synonym": ["rainbow"],
}
],
},
{
"setting_name": "none",
"setting_values": [
{"lang": "en", "setting_synonym": ["none"]}
],
},
],
}
],
"colorModel": "hsv", "colorModel": "hsv",
"colorTemperatureRange": { "colorTemperatureRange": {
"temperatureMinK": 2000, "temperatureMinK": 2000,
@ -186,7 +254,14 @@ async def test_sync_in_area(hass, registries):
async def test_query_message(hass): async def test_query_message(hass):
"""Test a sync message.""" """Test a sync message."""
light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75)) light = DemoLight(
None,
"Demo Light",
state=False,
hs_color=(180, 75),
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
)
light.hass = hass light.hass = hass
light.entity_id = "light.demo_light" light.entity_id = "light.demo_light"
await light.async_update_ha_state() await light.async_update_ha_state()
@ -555,7 +630,14 @@ async def test_serialize_input_boolean(hass):
async def test_unavailable_state_does_sync(hass): async def test_unavailable_state_does_sync(hass):
"""Test that an unavailable entity does sync over.""" """Test that an unavailable entity does sync over."""
light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75)) light = DemoLight(
None,
"Demo Light",
state=False,
hs_color=(180, 75),
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
)
light.hass = hass light.hass = hass
light.entity_id = "light.demo_light" light.entity_id = "light.demo_light"
light._available = False # pylint: disable=protected-access light._available = False # pylint: disable=protected-access
@ -584,10 +666,37 @@ async def test_unavailable_state_does_sync(hass):
trait.TRAIT_BRIGHTNESS, trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF, trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING, trait.TRAIT_COLOR_SETTING,
trait.TRAIT_MODES,
], ],
"type": const.TYPE_LIGHT, "type": const.TYPE_LIGHT,
"willReportState": False, "willReportState": False,
"attributes": { "attributes": {
"availableModes": [
{
"name": "effect",
"name_values": [
{"lang": "en", "name_synonym": ["effect"]}
],
"ordered": False,
"settings": [
{
"setting_name": "rainbow",
"setting_values": [
{
"lang": "en",
"setting_synonym": ["rainbow"],
}
],
},
{
"setting_name": "none",
"setting_values": [
{"lang": "en", "setting_synonym": ["none"]}
],
},
],
}
],
"colorModel": "hsv", "colorModel": "hsv",
"colorTemperatureRange": { "colorTemperatureRange": {
"temperatureMinK": 2000, "temperatureMinK": 2000,

View file

@ -515,6 +515,73 @@ async def test_color_light_temperature_light_bad_temp(hass):
assert trt.query_attributes() == {} assert trt.query_attributes() == {}
async def test_light_modes(hass):
"""Test Light Mode trait."""
assert helpers.get_google_type(light.DOMAIN, None) is not None
assert trait.ModesTrait.supported(light.DOMAIN, light.SUPPORT_EFFECT, None)
trt = trait.ModesTrait(
hass,
State(
"light.living_room",
light.STATE_ON,
attributes={
light.ATTR_EFFECT_LIST: ["random", "colorloop"],
light.ATTR_EFFECT: "random",
},
),
BASIC_CONFIG,
)
attribs = trt.sync_attributes()
assert attribs == {
"availableModes": [
{
"name": "effect",
"name_values": [{"name_synonym": ["effect"], "lang": "en"}],
"settings": [
{
"setting_name": "random",
"setting_values": [
{"setting_synonym": ["random"], "lang": "en"}
],
},
{
"setting_name": "colorloop",
"setting_values": [
{"setting_synonym": ["colorloop"], "lang": "en"}
],
},
],
"ordered": False,
}
]
}
assert trt.query_attributes() == {
"currentModeSettings": {"effect": "random"},
"on": True,
}
assert trt.can_execute(
trait.COMMAND_MODES, params={"updateModeSettings": {"effect": "colorloop"}},
)
calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON)
await trt.execute(
trait.COMMAND_MODES,
BASIC_DATA,
{"updateModeSettings": {"effect": "colorloop"}},
{},
)
assert len(calls) == 1
assert calls[0].data == {
"entity_id": "light.living_room",
"effect": "colorloop",
}
async def test_scene_scene(hass): async def test_scene_scene(hass):
"""Test Scene trait support for scene domain.""" """Test Scene trait support for scene domain."""
assert helpers.get_google_type(scene.DOMAIN, None) is not None assert helpers.get_google_type(scene.DOMAIN, None) is not None

View file

@ -545,13 +545,11 @@ async def test_service_calls(hass):
state = hass.states.get("light.ceiling_lights") state = hass.states.get("light.ceiling_lights")
assert state.state == STATE_ON assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128 assert state.attributes[ATTR_BRIGHTNESS] == 128
assert state.attributes[ATTR_EFFECT] == "Random"
assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255) assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255)
state = hass.states.get("light.kitchen_lights") state = hass.states.get("light.kitchen_lights")
assert state.state == STATE_ON assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128 assert state.attributes[ATTR_BRIGHTNESS] == 128
assert state.attributes[ATTR_EFFECT] == "Random"
assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255) assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255)