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
This commit is contained in:
Marcel van der Veldt 2023-01-31 05:32:37 +01:00 committed by GitHub
parent d88849fb04
commit f8c6e4c20a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 220 additions and 30 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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