From f8c6e4c20abe2984561d2ce6b590f59a0d04f34f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 31 Jan 2023 05:32:37 +0100 Subject: [PATCH] Add support for Hue Smart Scenes (Natural Lights) (#85517) * Bump aiohue to 4.6.0 * fix device name for lights * fix name for groups too * ignore smart scenes * bump to 4.6.1 instead * Add support for Smart Scenes (Natural lights) in Hue * update base entity class * fix test fixture * update tests * fix scene test * fix typo * use underlying scene controller * use enum value * update tests * add current scene name within smart scene * extra attributes are only valid if the scene is active * Update v2_resources.json * typo * fix after merge --- homeassistant/components/hue/scene.py | 100 +++++++++++---- homeassistant/components/hue/v2/entity.py | 8 +- .../components/hue/fixtures/v2_resources.json | 119 ++++++++++++++++++ tests/components/hue/test_light_v2.py | 5 +- tests/components/hue/test_scene.py | 18 ++- 5 files changed, 220 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index b93d8f76fed..8d4ca5d724a 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -5,11 +5,9 @@ from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType -from aiohue.v2.controllers.scenes import ( - Scene as HueScene, - ScenePut as HueScenePut, - ScenesController, -) +from aiohue.v2.controllers.scenes import ScenesController +from aiohue.v2.models.scene import Scene as HueScene, ScenePut as HueScenePut +from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartSceneState import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity @@ -45,12 +43,17 @@ async def async_setup_entry( # add entities for all scenes @callback - def async_add_entity(event_type: EventType, resource: HueScene) -> None: + def async_add_entity( + event_type: EventType, resource: HueScene | HueSmartScene + ) -> None: """Add entity from Hue resource.""" - async_add_entities([HueSceneEntity(bridge, api.scenes.scene, resource)]) + if isinstance(resource, HueSmartScene): + async_add_entities([HueSmartSceneEntity(bridge, api.scenes, resource)]) + else: + async_add_entities([HueSceneEntity(bridge, api.scenes, resource)]) # add all current items in controller - for item in api.scenes.scene: + for item in api.scenes: async_add_entity(EventType.RESOURCE_ADDED, item) # register listener for new items only @@ -78,14 +81,14 @@ async def async_setup_entry( ) -class HueSceneEntity(HueBaseEntity, SceneEntity): - """Representation of a Scene entity from Hue Scenes.""" +class HueSceneEntityBase(HueBaseEntity, SceneEntity): + """Base Representation of a Scene entity from Hue Scenes.""" def __init__( self, bridge: HueBridge, controller: ScenesController, - resource: HueScene, + resource: HueScene | HueSmartScene, ) -> None: """Initialize the entity.""" super().__init__(bridge, controller, resource) @@ -110,6 +113,25 @@ class HueSceneEntity(HueBaseEntity, SceneEntity): """Return default entity name.""" return f"{self.group.metadata.name} {self.resource.metadata.name}" + @property + def device_info(self) -> DeviceInfo: + """Return device (service) info.""" + # we create a virtual service/device for Hue scenes + # so we have a parent for grouped lights and scenes + return DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + entry_type=DeviceEntryType.SERVICE, + name=self.group.metadata.name, + manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, + model=self.group.type.value.title(), + suggested_area=self.group.metadata.name, + via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), + ) + + +class HueSceneEntity(HueSceneEntityBase): + """Representation of a Scene entity from Hue Scenes.""" + @property def is_dynamic(self) -> bool: """Return if this scene has a dynamic color palette.""" @@ -134,13 +156,13 @@ class HueSceneEntity(HueBaseEntity, SceneEntity): if speed is not None: await self.bridge.async_request_call( - self.controller.update, + self.controller.scene.update, self.resource.id, HueScenePut(speed=speed / 100), ) await self.bridge.async_request_call( - self.controller.recall, + self.controller.scene.recall, self.resource.id, dynamic=dynamic, duration=transition, @@ -169,17 +191,45 @@ class HueSceneEntity(HueBaseEntity, SceneEntity): "is_dynamic": self.is_dynamic, } + +class HueSmartSceneEntity(HueSceneEntityBase): + """Representation of a Smart Scene entity from Hue Scenes.""" + @property - def device_info(self) -> DeviceInfo: - """Return device (service) info.""" - # we create a virtual service/device for Hue scenes - # so we have a parent for grouped lights and scenes - return DeviceInfo( - identifiers={(DOMAIN, self.group.id)}, - entry_type=DeviceEntryType.SERVICE, - name=self.group.metadata.name, - manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, - model=self.group.type.value.title(), - suggested_area=self.group.metadata.name, - via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), + def is_active(self) -> bool: + """Return if this smart scene is currently active.""" + return self.resource.state == SmartSceneState.ACTIVE + + async def async_activate(self, **kwargs: Any) -> None: + """Activate Hue Smart scene.""" + + await self.bridge.async_request_call( + self.controller.smart_scene.recall, + self.resource.id, ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the optional state attributes.""" + res = { + "group_name": self.group.metadata.name, + "group_type": self.group.type.value, + "name": self.resource.metadata.name, + "is_active": self.is_active, + } + if self.is_active and self.resource.active_timeslot: + res["active_timeslot_id"] = self.resource.active_timeslot.timeslot_id + res["active_timeslot_name"] = self.resource.active_timeslot.weekday.value + # lookup active scene in timeslot + active_scene = None + count = 0 + for day_timeslot in self.resource.week_timeslots: + for timeslot in day_timeslot.timeslots: + if count != self.resource.active_timeslot.timeslot_id: + count += 1 + continue + active_scene = self.controller.get(timeslot.target.rid) + break + if active_scene is not None: + res["active_scene"] = active_scene.metadata.name + return res diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 04bb553cd36..85b70465854 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -139,11 +139,15 @@ class HueBaseEntity(Entity): if event_type == EventType.RESOURCE_DELETED: # remove any services created for zones/rooms # regular devices are removed automatically by the logic in device.py. - if resource.type in [ResourceTypes.ROOM, ResourceTypes.ZONE]: + if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): dev_reg = async_get_device_registry(self.hass) if device := dev_reg.async_get_device({(DOMAIN, resource.id)}): dev_reg.async_remove_device(device.id) - if resource.type in [ResourceTypes.GROUPED_LIGHT, ResourceTypes.SCENE]: + if resource.type in ( + ResourceTypes.GROUPED_LIGHT, + ResourceTypes.SCENE, + ResourceTypes.SMART_SCENE, + ): ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 51eff8451b8..a7ad7ec1a00 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -224,6 +224,125 @@ "auto_dynamic": false, "type": "scene" }, + { + "id": "redacted-8abe5a3e-94c8-4058-908f-56241818509a", + "type": "smart_scene", + "metadata": { + "name": "Smart Test Scene", + "image": { + "rid": "eb014820-a902-4652-8ca7-6e29c03b87a1", + "rtype": "public_image" + } + }, + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "week_timeslots": [ + { + "timeslots": [ + { + "start_time": { + "kind": "time", + "time": { + "hour": 7, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + }, + { + "start_time": { + "kind": "time", + "time": { + "hour": 10, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + }, + { + "start_time": { + "kind": "time", + "time": { + "hour": 16, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + }, + { + "start_time": { + "kind": "time", + "time": { + "hour": 20, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + }, + { + "start_time": { + "kind": "time", + "time": { + "hour": 22, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + }, + { + "start_time": { + "kind": "time", + "time": { + "hour": 0, + "minute": 0, + "second": 0 + } + }, + "target": { + "rid": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "rtype": "scene" + } + } + ], + "recurrence": [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday" + ] + } + ], + "active_timeslot": { + "timeslot_id": 1, + "weekday": "wednesday" + }, + "state": "active" + }, { "id": "3ff06175-29e8-44a8-8fe7-af591b0025da", "id_v1": "/sensors/50", diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 0427c9f7c4d..30047ef7dc1 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -369,7 +369,10 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): assert test_entity.attributes["min_mireds"] == 153 assert test_entity.attributes["max_mireds"] == 454 assert test_entity.attributes["is_hue_group"] is True - assert test_entity.attributes["hue_scenes"] == {"Regular Test Scene"} + assert test_entity.attributes["hue_scenes"] == { + "Regular Test Scene", + "Smart Test Scene", + } assert test_entity.attributes["hue_type"] == "room" assert test_entity.attributes["lights"] == { "Hue on/off light", diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index b0d9cf41c9f..31c276f6ce8 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -15,8 +15,8 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): await setup_platform(hass, mock_bridge_v2, "scene") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 entities should be created from test data - assert len(hass.states.async_all()) == 2 + # 3 entities should be created from test data + assert len(hass.states.async_all()) == 3 # test (dynamic) scene for a hue zone test_entity = hass.states.get("scene.test_zone_dynamic_test_scene") @@ -42,11 +42,25 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): assert test_entity.attributes["brightness"] == 100.0 assert test_entity.attributes["is_dynamic"] is False + # test smart scene + test_entity = hass.states.get("scene.test_room_smart_test_scene") + assert test_entity is not None + assert test_entity.name == "Test Room Smart Test Scene" + assert test_entity.state == STATE_UNKNOWN + assert test_entity.attributes["group_name"] == "Test Room" + assert test_entity.attributes["group_type"] == "room" + assert test_entity.attributes["name"] == "Smart Test Scene" + assert test_entity.attributes["active_timeslot_id"] == 1 + assert test_entity.attributes["active_timeslot_name"] == "wednesday" + assert test_entity.attributes["active_scene"] == "Regular Test Scene" + assert test_entity.attributes["is_active"] is True + # scene entities should have be assigned to the room/zone device/service ent_reg = er.async_get(hass) for entity_id in ( "scene.test_zone_dynamic_test_scene", "scene.test_room_regular_test_scene", + "scene.test_room_smart_test_scene", ): entity_entry = ent_reg.async_get(entity_id) assert entity_entry