mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
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,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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user