From 34e2a1825bf117e236d5ae479486a0c2afe324fd Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sat, 8 Aug 2020 14:28:04 -0700 Subject: [PATCH] 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. --- homeassistant/components/demo/light.py | 11 +- .../components/google_assistant/trait.py | 21 ++++ tests/components/google_assistant/__init__.py | 1 + .../google_assistant/test_smart_home.py | 119 +++++++++++++++++- .../components/google_assistant/test_trait.py | 67 ++++++++++ tests/components/group/test_light.py | 2 - 6 files changed, 208 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 11b6a4812e8..640502584f8 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -24,11 +24,7 @@ LIGHT_EFFECT_LIST = ["rainbow", "none"] LIGHT_TEMPS = [240, 380] SUPPORT_DEMO = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | SUPPORT_EFFECT - | SUPPORT_COLOR - | SUPPORT_WHITE_VALUE + SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR | SUPPORT_WHITE_VALUE ) @@ -81,10 +77,13 @@ class DemoLight(LightEntity): self._ct = ct or random.choice(LIGHT_TEMPS) self._brightness = brightness self._white = white + self._features = SUPPORT_DEMO self._effect_list = effect_list self._effect = effect self._available = True 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 def device_info(self): @@ -161,7 +160,7 @@ class DemoLight(LightEntity): @property def supported_features(self) -> int: """Flag supported features.""" - return SUPPORT_DEMO + return self._features async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 6ff19aedeb4..b347294f275 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1280,6 +1280,9 @@ class ModesTrait(_Trait): if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: return True + if domain == light.DOMAIN and features & light.SUPPORT_EFFECT: + return True + if domain != media_player.DOMAIN: return False @@ -1317,6 +1320,7 @@ class ModesTrait(_Trait): (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), + (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), ): if self.state.domain != domain: continue @@ -1347,6 +1351,9 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if humidifier.ATTR_MODE in attrs: 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: response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN) @@ -1386,6 +1393,20 @@ class ModesTrait(_Trait): ) 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: _LOGGER.info( "Received an Options command for unrecognised domain %s", diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index f665fa53ed2..f4e26a77f48 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -128,6 +128,7 @@ DEMO_DEVICES = [ "action.devices.traits.OnOff", "action.devices.traits.Brightness", "action.devices.traits.ColorSetting", + "action.devices.traits.Modes", ], "type": "action.devices.types.LIGHT", "willReportState": False, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6cd99d1fdd1..db97a42dff4 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -9,7 +9,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.components.demo.binary_sensor import DemoBinarySensor 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.switch import DemoSwitch from homeassistant.components.google_assistant import ( @@ -48,7 +48,14 @@ def registries(hass): async def test_sync_message(hass): """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.entity_id = "light.demo_light" await light.async_update_ha_state() @@ -95,10 +102,37 @@ async def test_sync_message(hass): trait.TRAIT_BRIGHTNESS, trait.TRAIT_ONOFF, trait.TRAIT_COLOR_SETTING, + trait.TRAIT_MODES, ], "type": const.TYPE_LIGHT, "willReportState": False, "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", "colorTemperatureRange": { "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 = 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.entity_id = entity.entity_id await light.async_update_ha_state() @@ -162,10 +203,37 @@ async def test_sync_in_area(hass, registries): trait.TRAIT_BRIGHTNESS, trait.TRAIT_ONOFF, trait.TRAIT_COLOR_SETTING, + trait.TRAIT_MODES, ], "type": const.TYPE_LIGHT, "willReportState": False, "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", "colorTemperatureRange": { "temperatureMinK": 2000, @@ -186,7 +254,14 @@ async def test_sync_in_area(hass, registries): async def test_query_message(hass): """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.entity_id = "light.demo_light" 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): """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.entity_id = "light.demo_light" light._available = False # pylint: disable=protected-access @@ -584,10 +666,37 @@ async def test_unavailable_state_does_sync(hass): trait.TRAIT_BRIGHTNESS, trait.TRAIT_ONOFF, trait.TRAIT_COLOR_SETTING, + trait.TRAIT_MODES, ], "type": const.TYPE_LIGHT, "willReportState": False, "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", "colorTemperatureRange": { "temperatureMinK": 2000, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 54a8acf1a65..9d821b357ca 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -515,6 +515,73 @@ async def test_color_light_temperature_light_bad_temp(hass): 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): """Test Scene trait support for scene domain.""" assert helpers.get_google_type(scene.DOMAIN, None) is not None diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 2a2e21f77c5..685db475b8c 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -545,13 +545,11 @@ async def test_service_calls(hass): state = hass.states.get("light.ceiling_lights") assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 128 - assert state.attributes[ATTR_EFFECT] == "Random" assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255) state = hass.states.get("light.kitchen_lights") assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 128 - assert state.attributes[ATTR_EFFECT] == "Random" assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255)