Add virtual device/service for Hue groups (#68569)

This commit is contained in:
Marcel van der Veldt 2022-03-30 05:33:05 +02:00 committed by GitHub
parent 7e8d52e5a3
commit 09f6785956
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 75 additions and 67 deletions

View File

@ -16,6 +16,8 @@ from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from .bridge import HueBridge
from .const import DOMAIN
@ -106,8 +108,7 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
@property
def name(self) -> str:
"""Return default entity name."""
group = self.controller.get_group(self.resource.id)
return f"{group.metadata.name} - {self.resource.metadata.name}"
return f"{self.group.metadata.name} {self.resource.metadata.name}"
@property
def is_dynamic(self) -> bool:
@ -167,3 +168,18 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
"brightness": brightness,
"is_dynamic": self.is_dynamic,
}
@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),
)

View File

@ -9,6 +9,7 @@ from aiohue.v2.models.resource import ResourceTypes
from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus
from homeassistant.core import callback
from homeassistant.helpers.device_registry import async_get as async_get_device_registry
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
@ -135,17 +136,21 @@ class HueBaseEntity(Entity):
@callback
def _handle_event(self, event_type: EventType, resource: HueResource) -> None:
"""Handle status event for this resource (or it's parent)."""
if event_type == EventType.RESOURCE_DELETED and resource.id == self.resource.id:
self.logger.debug("Received delete for %s", self.entity_id)
# non-device bound entities like groups and scenes need to be removed here
# all others will be be removed by device setup in case of device removal
ent_reg = async_get_entity_registry(self.hass)
ent_reg.async_remove(self.entity_id)
else:
self.logger.debug("Received status update for %s", self.entity_id)
self._check_availability()
self.on_update()
self.async_write_ha_state()
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]:
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]:
ent_reg = async_get_entity_registry(self.hass)
ent_reg.async_remove(self.entity_id)
return
self.logger.debug("Received status update for %s", self.entity_id)
self._check_availability()
self.on_update()
self.async_write_ha_state()
@callback
def _check_availability(self):

View File

@ -25,10 +25,12 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from ..bridge import HueBridge
from ..const import CONF_ALLOW_HUE_GROUPS, DOMAIN
from ..const import DOMAIN
from .entity import HueBaseEntity
from .helpers import (
normalize_hue_brightness,
@ -46,30 +48,22 @@ async def async_setup_entry(
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
api: HueBridgeV2 = bridge.api
# to prevent race conditions (groupedlight is created before zone/room)
# we create groupedlights from the room/zone and actually use the
# underlying grouped_light resource for control
@callback
def async_add_light(event_type: EventType, resource: Room | Zone) -> None:
def async_add_light(event_type: EventType, resource: GroupedLight) -> None:
"""Add Grouped Light for Hue Room/Zone."""
if grouped_light_id := resource.grouped_light:
grouped_light = api.groups.grouped_light[grouped_light_id]
light = GroupedHueLight(bridge, grouped_light, resource)
async_add_entities([light])
group = api.groups.grouped_light.get_zone(resource.id)
if group is None:
return
light = GroupedHueLight(bridge, resource, group)
async_add_entities([light])
# add current items
for item in api.groups.room.items + api.groups.zone.items:
for item in api.groups.grouped_light.items:
async_add_light(EventType.RESOURCE_ADDED, item)
# register listener for new zones/rooms
# register listener for new grouped_light
config_entry.async_on_unload(
api.groups.room.subscribe(
async_add_light, event_filter=EventType.RESOURCE_ADDED
)
)
config_entry.async_on_unload(
api.groups.zone.subscribe(
api.groups.grouped_light.subscribe(
async_add_light, event_filter=EventType.RESOURCE_ADDED
)
)
@ -93,11 +87,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
self._attr_supported_features |= SUPPORT_FLASH
self._attr_supported_features |= SUPPORT_TRANSITION
# Entities for Hue groups are disabled by default
# unless they were enabled in old version (legacy option)
self._attr_entity_registry_enabled_default = bridge.config_entry.options.get(
CONF_ALLOW_HUE_GROUPS, False
)
self._dynamic_mode_active = False
self._update_values()
@ -144,6 +133,22 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
"dynamics": self._dynamic_mode_active,
}
@property
def device_info(self) -> DeviceInfo:
"""Return device (service) info."""
# we create a virtual service/device for Hue zones/rooms
# so we have a parent for grouped lights and scenes
model = self.group.type.value.title()
return DeviceInfo(
identifiers={(DOMAIN, self.group.id)},
entry_type=DeviceEntryType.SERVICE,
name=self.group.metadata.name,
manufacturer=self.api.config.bridge_device.product_data.manufacturer_name,
model=model,
suggested_area=self.group.metadata.name if model == "Room" else None,
via_device=(DOMAIN, self.api.config.bridge_device.id),
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the grouped_light on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))

View File

@ -14,8 +14,8 @@ async def test_lights(hass, mock_bridge_v2, v2_resources_test_data):
await setup_platform(hass, mock_bridge_v2, "light")
# there shouldn't have been any requests at this point
assert len(mock_bridge_v2.mock_requests) == 0
# 6 entities should be created from test data (grouped_lights are disabled by default)
assert len(hass.states.async_all()) == 6
# 8 entities should be created from test data
assert len(hass.states.async_all()) == 8
# test light which supports color and color temperature
light_1 = hass.states.get("light.hue_light_with_color_and_color_temperature_1")
@ -329,32 +329,14 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data):
await setup_platform(hass, mock_bridge_v2, "light")
# test if entities for hue groups are created and disabled by default
# test if entities for hue groups are created and enabled by default
for entity_id in ("light.test_zone", "light.test_room"):
ent_reg = er.async_get(hass)
entity_entry = ent_reg.async_get(entity_id)
assert entity_entry
assert entity_entry.disabled
assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
# entity should not have a device assigned
assert entity_entry.device_id is None
# enable the entity
updated_entry = ent_reg.async_update_entity(
entity_entry.entity_id, **{"disabled_by": None}
)
assert updated_entry != entity_entry
assert updated_entry.disabled is False
# reload platform and check if entities are correctly there
await hass.config_entries.async_forward_entry_unload(
mock_bridge_v2.config_entry, "light"
)
await hass.config_entries.async_forward_entry_setup(
mock_bridge_v2.config_entry, "light"
)
await hass.async_block_till_done()
# scene entities should have be assigned to the room/zone device/service
assert entity_entry.device_id is not None
# test light created for hue zone
test_entity = hass.states.get("light.test_zone")

View File

@ -21,7 +21,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data):
# test (dynamic) scene for a hue zone
test_entity = hass.states.get("scene.test_zone_dynamic_test_scene")
assert test_entity is not None
assert test_entity.name == "Test Zone - Dynamic Test Scene"
assert test_entity.name == "Test Zone Dynamic Test Scene"
assert test_entity.state == STATE_UNKNOWN
assert test_entity.attributes["group_name"] == "Test Zone"
assert test_entity.attributes["group_type"] == "zone"
@ -33,7 +33,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data):
# test (regular) scene for a hue room
test_entity = hass.states.get("scene.test_room_regular_test_scene")
assert test_entity is not None
assert test_entity.name == "Test Room - Regular Test Scene"
assert test_entity.name == "Test Room Regular Test Scene"
assert test_entity.state == STATE_UNKNOWN
assert test_entity.attributes["group_name"] == "Test Room"
assert test_entity.attributes["group_type"] == "room"
@ -42,7 +42,7 @@ 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
# scene entities should not have a device assigned
# 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",
@ -50,7 +50,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data):
):
entity_entry = ent_reg.async_get(entity_id)
assert entity_entry
assert entity_entry.device_id is None
assert entity_entry.device_id is not None
async def test_scene_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data):
@ -144,7 +144,7 @@ async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data):
test_entity = hass.states.get(test_entity_id)
assert test_entity is not None
assert test_entity.state == STATE_UNKNOWN
assert test_entity.name == "Test Room - Mocked Scene"
assert test_entity.name == "Test Room Mocked Scene"
assert test_entity.attributes["brightness"] == 65.0
# test update
@ -156,7 +156,7 @@ async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data):
assert test_entity is not None
assert test_entity.attributes["brightness"] == 35.0
# test entity name changes on group name change
# # test entity name changes on group name change
mock_bridge_v2.api.emit_event(
"update",
{
@ -167,9 +167,9 @@ async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data):
)
await hass.async_block_till_done()
test_entity = hass.states.get(test_entity_id)
assert test_entity.name == "Test Room 2 - Mocked Scene"
assert test_entity.name == "Test Room 2 Mocked Scene"
# test delete
# # test delete
mock_bridge_v2.api.emit_event("delete", updated_resource)
await hass.async_block_till_done()
await hass.async_block_till_done()