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:
parent
d88849fb04
commit
f8c6e4c20a
5 changed files with 220 additions and 30 deletions
|
@ -5,11 +5,9 @@ from typing import Any
|
||||||
|
|
||||||
from aiohue.v2 import HueBridgeV2
|
from aiohue.v2 import HueBridgeV2
|
||||||
from aiohue.v2.controllers.events import EventType
|
from aiohue.v2.controllers.events import EventType
|
||||||
from aiohue.v2.controllers.scenes import (
|
from aiohue.v2.controllers.scenes import ScenesController
|
||||||
Scene as HueScene,
|
from aiohue.v2.models.scene import Scene as HueScene, ScenePut as HueScenePut
|
||||||
ScenePut as HueScenePut,
|
from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartSceneState
|
||||||
ScenesController,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity
|
from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity
|
||||||
|
@ -45,12 +43,17 @@ async def async_setup_entry(
|
||||||
|
|
||||||
# add entities for all scenes
|
# add entities for all scenes
|
||||||
@callback
|
@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."""
|
"""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
|
# add all current items in controller
|
||||||
for item in api.scenes.scene:
|
for item in api.scenes:
|
||||||
async_add_entity(EventType.RESOURCE_ADDED, item)
|
async_add_entity(EventType.RESOURCE_ADDED, item)
|
||||||
|
|
||||||
# register listener for new items only
|
# register listener for new items only
|
||||||
|
@ -78,14 +81,14 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HueSceneEntity(HueBaseEntity, SceneEntity):
|
class HueSceneEntityBase(HueBaseEntity, SceneEntity):
|
||||||
"""Representation of a Scene entity from Hue Scenes."""
|
"""Base Representation of a Scene entity from Hue Scenes."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bridge: HueBridge,
|
bridge: HueBridge,
|
||||||
controller: ScenesController,
|
controller: ScenesController,
|
||||||
resource: HueScene,
|
resource: HueScene | HueSmartScene,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(bridge, controller, resource)
|
super().__init__(bridge, controller, resource)
|
||||||
|
@ -110,6 +113,25 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
|
||||||
"""Return default entity name."""
|
"""Return default entity name."""
|
||||||
return f"{self.group.metadata.name} {self.resource.metadata.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
|
@property
|
||||||
def is_dynamic(self) -> bool:
|
def is_dynamic(self) -> bool:
|
||||||
"""Return if this scene has a dynamic color palette."""
|
"""Return if this scene has a dynamic color palette."""
|
||||||
|
@ -134,13 +156,13 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
|
||||||
|
|
||||||
if speed is not None:
|
if speed is not None:
|
||||||
await self.bridge.async_request_call(
|
await self.bridge.async_request_call(
|
||||||
self.controller.update,
|
self.controller.scene.update,
|
||||||
self.resource.id,
|
self.resource.id,
|
||||||
HueScenePut(speed=speed / 100),
|
HueScenePut(speed=speed / 100),
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bridge.async_request_call(
|
await self.bridge.async_request_call(
|
||||||
self.controller.recall,
|
self.controller.scene.recall,
|
||||||
self.resource.id,
|
self.resource.id,
|
||||||
dynamic=dynamic,
|
dynamic=dynamic,
|
||||||
duration=transition,
|
duration=transition,
|
||||||
|
@ -169,17 +191,45 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
|
||||||
"is_dynamic": self.is_dynamic,
|
"is_dynamic": self.is_dynamic,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HueSmartSceneEntity(HueSceneEntityBase):
|
||||||
|
"""Representation of a Smart Scene entity from Hue Scenes."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def is_active(self) -> bool:
|
||||||
"""Return device (service) info."""
|
"""Return if this smart scene is currently active."""
|
||||||
# we create a virtual service/device for Hue scenes
|
return self.resource.state == SmartSceneState.ACTIVE
|
||||||
# so we have a parent for grouped lights and scenes
|
|
||||||
return DeviceInfo(
|
async def async_activate(self, **kwargs: Any) -> None:
|
||||||
identifiers={(DOMAIN, self.group.id)},
|
"""Activate Hue Smart scene."""
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
name=self.group.metadata.name,
|
await self.bridge.async_request_call(
|
||||||
manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name,
|
self.controller.smart_scene.recall,
|
||||||
model=self.group.type.value.title(),
|
self.resource.id,
|
||||||
suggested_area=self.group.metadata.name,
|
|
||||||
via_device=(DOMAIN, self.bridge.api.config.bridge_device.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
|
||||||
|
|
|
@ -139,11 +139,15 @@ class HueBaseEntity(Entity):
|
||||||
if event_type == EventType.RESOURCE_DELETED:
|
if event_type == EventType.RESOURCE_DELETED:
|
||||||
# remove any services created for zones/rooms
|
# remove any services created for zones/rooms
|
||||||
# regular devices are removed automatically by the logic in device.py.
|
# 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)
|
dev_reg = async_get_device_registry(self.hass)
|
||||||
if device := dev_reg.async_get_device({(DOMAIN, resource.id)}):
|
if device := dev_reg.async_get_device({(DOMAIN, resource.id)}):
|
||||||
dev_reg.async_remove_device(device.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_get_entity_registry(self.hass)
|
||||||
ent_reg.async_remove(self.entity_id)
|
ent_reg.async_remove(self.entity_id)
|
||||||
return
|
return
|
||||||
|
|
|
@ -224,6 +224,125 @@
|
||||||
"auto_dynamic": false,
|
"auto_dynamic": false,
|
||||||
"type": "scene"
|
"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": "3ff06175-29e8-44a8-8fe7-af591b0025da",
|
||||||
"id_v1": "/sensors/50",
|
"id_v1": "/sensors/50",
|
||||||
|
|
|
@ -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["min_mireds"] == 153
|
||||||
assert test_entity.attributes["max_mireds"] == 454
|
assert test_entity.attributes["max_mireds"] == 454
|
||||||
assert test_entity.attributes["is_hue_group"] is True
|
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["hue_type"] == "room"
|
||||||
assert test_entity.attributes["lights"] == {
|
assert test_entity.attributes["lights"] == {
|
||||||
"Hue on/off light",
|
"Hue on/off light",
|
||||||
|
|
|
@ -15,8 +15,8 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data):
|
||||||
await setup_platform(hass, mock_bridge_v2, "scene")
|
await setup_platform(hass, mock_bridge_v2, "scene")
|
||||||
# there shouldn't have been any requests at this point
|
# there shouldn't have been any requests at this point
|
||||||
assert len(mock_bridge_v2.mock_requests) == 0
|
assert len(mock_bridge_v2.mock_requests) == 0
|
||||||
# 2 entities should be created from test data
|
# 3 entities should be created from test data
|
||||||
assert len(hass.states.async_all()) == 2
|
assert len(hass.states.async_all()) == 3
|
||||||
|
|
||||||
# test (dynamic) scene for a hue zone
|
# test (dynamic) scene for a hue zone
|
||||||
test_entity = hass.states.get("scene.test_zone_dynamic_test_scene")
|
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["brightness"] == 100.0
|
||||||
assert test_entity.attributes["is_dynamic"] is False
|
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
|
# scene entities should have be assigned to the room/zone device/service
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
for entity_id in (
|
for entity_id in (
|
||||||
"scene.test_zone_dynamic_test_scene",
|
"scene.test_zone_dynamic_test_scene",
|
||||||
"scene.test_room_regular_test_scene",
|
"scene.test_room_regular_test_scene",
|
||||||
|
"scene.test_room_smart_test_scene",
|
||||||
):
|
):
|
||||||
entity_entry = ent_reg.async_get(entity_id)
|
entity_entry = ent_reg.async_get(entity_id)
|
||||||
assert entity_entry
|
assert entity_entry
|
||||||
|
|
Loading…
Add table
Reference in a new issue