From 09f678595605a1726bbe8e135b89708b5876f0bd Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 30 Mar 2022 05:33:05 +0200 Subject: [PATCH] Add virtual device/service for Hue groups (#68569) --- homeassistant/components/hue/scene.py | 20 ++++++++- homeassistant/components/hue/v2/entity.py | 27 +++++++----- homeassistant/components/hue/v2/group.py | 51 +++++++++++++---------- tests/components/hue/test_light_v2.py | 28 +++---------- tests/components/hue/test_scene.py | 16 +++---- 5 files changed, 75 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index c21a96e4d9a..1504e52e33d 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -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), + ) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 721425606bc..fec14075b42 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -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): diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 4816ce55231..0eac6948790 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -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)) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c6b114ff612..203d2983fb1 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -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") diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 7f30fd25681..b0d9cf41c9f 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -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()