Adjust Hue integration to use Entity descriptions and translatable entity names (#101413)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Marcel van der Veldt 2023-10-09 14:14:07 +02:00 committed by GitHub
parent 8a83e810b8
commit 6393171fa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 266 additions and 209 deletions

View File

@ -71,6 +71,7 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
key="button", key="button",
device_class=EventDeviceClass.BUTTON, device_class=EventDeviceClass.BUTTON,
translation_key="button", translation_key="button",
has_entity_name=True,
) )
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
@ -89,7 +90,8 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
@property @property
def name(self) -> str: def name(self) -> str:
"""Return name for the entity.""" """Return name for the entity."""
return f"{super().name} {self.resource.metadata.control_id}" # this can be translated too as soon as we support arguments into translations ?
return f"Button {self.resource.metadata.control_id}"
@callback @callback
def _handle_event(self, event_type: EventType, resource: Button) -> None: def _handle_event(self, event_type: EventType, resource: Button) -> None:
@ -112,6 +114,7 @@ class HueRotaryEventEntity(HueBaseEntity, EventEntity):
RelativeRotaryDirection.CLOCK_WISE.value, RelativeRotaryDirection.CLOCK_WISE.value,
RelativeRotaryDirection.COUNTER_CLOCK_WISE.value, RelativeRotaryDirection.COUNTER_CLOCK_WISE.value,
], ],
has_entity_name=True,
) )
@callback @callback

View File

@ -13,7 +13,7 @@ import voluptuous as vol
from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import (
AddEntitiesCallback, AddEntitiesCallback,
async_get_current_platform, async_get_current_platform,
@ -86,6 +86,8 @@ async def async_setup_entry(
class HueSceneEntityBase(HueBaseEntity, SceneEntity): class HueSceneEntityBase(HueBaseEntity, SceneEntity):
"""Base Representation of a Scene entity from Hue Scenes.""" """Base Representation of a Scene entity from Hue Scenes."""
_attr_has_entity_name = True
def __init__( def __init__(
self, self,
bridge: HueBridge, bridge: HueBridge,
@ -97,6 +99,11 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity):
self.resource = resource self.resource = resource
self.controller = controller self.controller = controller
self.group = self.controller.get_group(self.resource.id) self.group = self.controller.get_group(self.resource.id)
# we create a virtual service/device for Hue zones/rooms
# so we have a parent for grouped lights and scenes
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.group.id)},
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added.""" """Call when entity is added."""
@ -112,24 +119,8 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity):
@property @property
def name(self) -> str: def name(self) -> str:
"""Return default entity name.""" """Return name of the scene."""
return f"{self.group.metadata.name} {self.resource.metadata.name}" return 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
group_type = self.group.type.value.title()
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 if group_type == "Room" else None,
via_device=(DOMAIN, self.bridge.api.config.bridge_device.id),
)
class HueSceneEntity(HueSceneEntityBase): class HueSceneEntity(HueSceneEntityBase):

View File

@ -97,6 +97,7 @@
}, },
"sensor": { "sensor": {
"zigbee_connectivity": { "zigbee_connectivity": {
"name": "Zigbee connectivity",
"state": { "state": {
"connected": "[%key:common::state::connected%]", "connected": "[%key:common::state::connected%]",
"disconnected": "[%key:common::state::disconnected%]", "disconnected": "[%key:common::state::disconnected%]",
@ -106,11 +107,11 @@
} }
}, },
"switch": { "switch": {
"automation": { "motion_sensor_enabled": {
"state": { "name": "Motion sensor enabled"
"on": "[%key:common::state::enabled%]", },
"off": "[%key:common::state::disabled%]" "light_sensor_enabled": {
} "name": "Light sensor enabled"
} }
} }
}, },

View File

@ -1,7 +1,7 @@
"""Support for switch platform for Hue resources (V2 only).""" """Support for switch platform for Hue resources (V2 only)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, TypeAlias from typing import Any
from aiohue.v2 import HueBridgeV2 from aiohue.v2 import HueBridgeV2
from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController
@ -27,12 +27,6 @@ from .bridge import HueBridge
from .const import DOMAIN from .const import DOMAIN
from .v2.entity import HueBaseEntity from .v2.entity import HueBaseEntity
ControllerType: TypeAlias = (
BehaviorInstanceController | LightLevelController | MotionController
)
SensingService: TypeAlias = LightLevel | Motion
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -48,20 +42,22 @@ async def async_setup_entry(
raise NotImplementedError("Switch support is only available for V2 bridges") raise NotImplementedError("Switch support is only available for V2 bridges")
@callback @callback
def register_items(controller: ControllerType): def register_items(
controller: BehaviorInstanceController
| LightLevelController
| MotionController,
switch_class: type[
HueBehaviorInstanceEnabledEntity
| HueLightSensorEnabledEntity
| HueMotionSensorEnabledEntity
],
):
@callback @callback
def async_add_entity( def async_add_entity(
event_type: EventType, resource: SensingService | BehaviorInstance event_type: EventType, resource: BehaviorInstance | LightLevel | Motion
) -> None: ) -> None:
"""Add entity from Hue resource.""" """Add entity from Hue resource."""
if isinstance(resource, BehaviorInstance): async_add_entities([switch_class(bridge, api.sensors.motion, resource)])
async_add_entities(
[HueBehaviorInstanceEnabledEntity(bridge, controller, resource)]
)
else:
async_add_entities(
[HueSensingServiceEnabledEntity(bridge, controller, resource)]
)
# add all current items in controller # add all current items in controller
for item in controller: for item in controller:
@ -75,15 +71,23 @@ async def async_setup_entry(
) )
# setup for each switch-type hue resource # setup for each switch-type hue resource
register_items(api.sensors.motion) register_items(api.sensors.motion, HueMotionSensorEnabledEntity)
register_items(api.sensors.light_level) register_items(api.sensors.light_level, HueLightSensorEnabledEntity)
register_items(api.config.behavior_instance) register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity)
class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity):
"""Representation of a Switch entity from a Hue resource that can be toggled enabled.""" """Representation of a Switch entity from a Hue resource that can be toggled enabled."""
controller: BehaviorInstanceController | LightLevelController | MotionController controller: BehaviorInstanceController | LightLevelController | MotionController
resource: BehaviorInstance | LightLevel | Motion
entity_description = SwitchEntityDescription(
key="sensing_service_enabled",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
has_entity_name=True,
)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -103,16 +107,6 @@ class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity):
) )
class HueSensingServiceEnabledEntity(HueResourceEnabledEntity):
"""Representation of a Switch entity from Hue SensingService."""
entity_description = SwitchEntityDescription(
key="behavior_instance",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
)
class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity):
"""Representation of a Switch entity to enable/disable a Hue Behavior Instance.""" """Representation of a Switch entity to enable/disable a Hue Behavior Instance."""
@ -123,10 +117,33 @@ class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity):
device_class=SwitchDeviceClass.SWITCH, device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
has_entity_name=False, has_entity_name=False,
translation_key="automation",
) )
@property @property
def name(self) -> str: def name(self) -> str:
"""Return name for this entity.""" """Return name for this entity."""
return f"Automation: {self.resource.metadata.name}" return f"Automation: {self.resource.metadata.name}"
class HueMotionSensorEnabledEntity(HueResourceEnabledEntity):
"""Representation of a Switch entity to enable/disable a Hue motion sensor."""
entity_description = SwitchEntityDescription(
key="motion_sensor_enabled",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
has_entity_name=True,
translation_key="motion_sensor_enabled",
)
class HueLightSensorEnabledEntity(HueResourceEnabledEntity):
"""Representation of a Switch entity to enable/disable a Hue light sensor."""
entity_description = SwitchEntityDescription(
key="light_sensor_enabled",
device_class=SwitchDeviceClass.SWITCH,
entity_category=EntityCategory.CONFIG,
has_entity_name=True,
translation_key="light_sensor_enabled",
)

View File

@ -24,8 +24,10 @@ from aiohue.v2.models.tamper import Tamper, TamperState
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -80,25 +82,17 @@ async def async_setup_entry(
register_items(api.sensors.tamper, HueTamperSensor) register_items(api.sensors.tamper, HueTamperSensor)
class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): class HueMotionSensor(HueBaseEntity, BinarySensorEntity):
"""Representation of a Hue binary_sensor."""
def __init__(
self,
bridge: HueBridge,
controller: ControllerType,
resource: SensorType,
) -> None:
"""Initialize the binary sensor."""
super().__init__(bridge, controller, resource)
self.resource = resource
self.controller = controller
class HueMotionSensor(HueBinarySensorBase):
"""Representation of a Hue Motion sensor.""" """Representation of a Hue Motion sensor."""
_attr_device_class = BinarySensorDeviceClass.MOTION controller: CameraMotionController | MotionController
resource: CameraMotion | Motion
entity_description = BinarySensorEntityDescription(
key="motion_sensor",
device_class=BinarySensorDeviceClass.MOTION,
has_entity_name=True,
)
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@ -109,10 +103,17 @@ class HueMotionSensor(HueBinarySensorBase):
return self.resource.motion.value return self.resource.motion.value
class HueEntertainmentActiveSensor(HueBinarySensorBase): class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity):
"""Representation of a Hue Entertainment Configuration as binary sensor.""" """Representation of a Hue Entertainment Configuration as binary sensor."""
_attr_device_class = BinarySensorDeviceClass.RUNNING controller: EntertainmentConfigurationController
resource: EntertainmentConfiguration
entity_description = BinarySensorEntityDescription(
key="entertainment_active_sensor",
device_class=BinarySensorDeviceClass.RUNNING,
has_entity_name=False,
)
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@ -122,14 +123,20 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase):
@property @property
def name(self) -> str: def name(self) -> str:
"""Return sensor name.""" """Return sensor name."""
type_title = self.resource.type.value.replace("_", " ").title() return self.resource.metadata.name
return f"{self.resource.metadata.name}: {type_title}"
class HueContactSensor(HueBinarySensorBase): class HueContactSensor(HueBaseEntity, BinarySensorEntity):
"""Representation of a Hue Contact sensor.""" """Representation of a Hue Contact sensor."""
_attr_device_class = BinarySensorDeviceClass.OPENING controller: ContactController
resource: Contact
entity_description = BinarySensorEntityDescription(
key="contact_sensor",
device_class=BinarySensorDeviceClass.OPENING,
has_entity_name=True,
)
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@ -140,10 +147,18 @@ class HueContactSensor(HueBinarySensorBase):
return self.resource.contact_report.state != ContactState.CONTACT return self.resource.contact_report.state != ContactState.CONTACT
class HueTamperSensor(HueBinarySensorBase): class HueTamperSensor(HueBaseEntity, BinarySensorEntity):
"""Representation of a Hue Tamper sensor.""" """Representation of a Hue Tamper sensor."""
_attr_device_class = BinarySensorDeviceClass.TAMPER controller: TamperController
resource: Tamper
entity_description = BinarySensorEntityDescription(
key="tamper_sensor",
device_class=BinarySensorDeviceClass.TAMPER,
entity_category=EntityCategory.DIAGNOSTIC,
has_entity_name=True,
)
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:

View File

@ -3,7 +3,9 @@ from typing import TYPE_CHECKING
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.groups import Room, Zone
from aiohue.v2.models.device import Device, DeviceArchetypes from aiohue.v2.models.device import Device, DeviceArchetypes
from aiohue.v2.models.resource import ResourceTypes
from homeassistant.const import ( from homeassistant.const import (
ATTR_CONNECTIONS, ATTR_CONNECTIONS,
@ -33,23 +35,38 @@ async def async_setup_devices(bridge: "HueBridge"):
dev_controller = api.devices dev_controller = api.devices
@callback @callback
def add_device(hue_device: Device) -> dr.DeviceEntry: def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry:
"""Register a Hue device in device registry.""" """Register a Hue device in device registry."""
model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" if isinstance(hue_resource, (Room, Zone)):
# Register a Hue Room/Zone as service in HA device registry.
return dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN, hue_resource.id)},
name=hue_resource.metadata.name,
model=hue_resource.type.value.title(),
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
via_device=(DOMAIN, api.config.bridge_device.id),
suggested_area=hue_resource.metadata.name
if hue_resource.type == ResourceTypes.ROOM
else None,
)
# Register a Hue device resource as device in HA device registry.
model = f"{hue_resource.product_data.product_name} ({hue_resource.product_data.model_id})"
params = { params = {
ATTR_IDENTIFIERS: {(DOMAIN, hue_device.id)}, ATTR_IDENTIFIERS: {(DOMAIN, hue_resource.id)},
ATTR_SW_VERSION: hue_device.product_data.software_version, ATTR_SW_VERSION: hue_resource.product_data.software_version,
ATTR_NAME: hue_device.metadata.name, ATTR_NAME: hue_resource.metadata.name,
ATTR_MODEL: model, ATTR_MODEL: model,
ATTR_MANUFACTURER: hue_device.product_data.manufacturer_name, ATTR_MANUFACTURER: hue_resource.product_data.manufacturer_name,
} }
if room := dev_controller.get_room(hue_device.id): if room := dev_controller.get_room(hue_resource.id):
params[ATTR_SUGGESTED_AREA] = room.metadata.name params[ATTR_SUGGESTED_AREA] = room.metadata.name
if hue_device.metadata.archetype == DeviceArchetypes.BRIDGE_V2: if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2:
params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id))
else: else:
params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id)
zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) zigbee = dev_controller.get_zigbee_connectivity(hue_resource.id)
if zigbee and zigbee.mac_address: if zigbee and zigbee.mac_address:
params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)}
@ -63,25 +80,27 @@ async def async_setup_devices(bridge: "HueBridge"):
dev_reg.async_remove_device(device.id) dev_reg.async_remove_device(device.id)
@callback @callback
def handle_device_event(evt_type: EventType, hue_device: Device) -> None: def handle_device_event(
"""Handle event from Hue devices controller.""" evt_type: EventType, hue_resource: Device | Room | Zone
) -> None:
"""Handle event from Hue controller."""
if evt_type == EventType.RESOURCE_DELETED: if evt_type == EventType.RESOURCE_DELETED:
remove_device(hue_device.id) remove_device(hue_resource.id)
else: else:
# updates to existing device will also be handled by this call # updates to existing device will also be handled by this call
add_device(hue_device) add_device(hue_resource)
# create/update all current devices found in controller # create/update all current devices found in controllers
known_devices = [add_device(hue_device) for hue_device in dev_controller] known_devices = [add_device(hue_device) for hue_device in dev_controller]
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
# Check for nodes that no longer exist and remove them # Check for nodes that no longer exist and remove them
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
if device not in known_devices: if device not in known_devices:
# handle case where a virtual device was created for a Hue group
hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN)
if hue_dev_id in api.groups:
continue
dev_reg.async_remove_device(device.id) dev_reg.async_remove_device(device.id)
# add listener for updates on Hue devices controller # add listener for updates on Hue controllers
entry.async_on_unload(dev_controller.subscribe(handle_device_event)) entry.async_on_unload(dev_controller.subscribe(handle_device_event))
entry.async_on_unload(api.groups.room.subscribe(handle_device_event))
entry.async_on_unload(api.groups.zone.subscribe(handle_device_event))

View File

@ -9,10 +9,7 @@ from aiohue.v2.models.resource import ResourceTypes
from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import DeviceInfo
DeviceInfo,
async_get as async_get_device_registry,
)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
@ -72,24 +69,6 @@ class HueBaseEntity(Entity):
self._ignore_availability = None self._ignore_availability = None
self._last_state = None self._last_state = None
@property
def name(self) -> str:
"""Return name for the entity."""
if self.device is None:
# this is just a guard
# creating a pretty name for device-less entities (e.g. groups/scenes)
# should be handled in the platform instead
return self.resource.type.value
dev_name = self.device.metadata.name
# if resource is a light, use the device name itself
if self.resource.type == ResourceTypes.LIGHT:
return dev_name
# for sensors etc, use devicename + pretty name of type
type_title = RESOURCE_TYPE_NAMES.get(
self.resource.type, self.resource.type.value.replace("_", " ").title()
)
return f"{dev_name} {type_title}"
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added.""" """Call when entity is added."""
self._check_availability() self._check_availability()
@ -146,19 +125,12 @@ class HueBaseEntity(Entity):
def _handle_event(self, event_type: EventType, resource: HueResource) -> None: def _handle_event(self, event_type: EventType, resource: HueResource) -> None:
"""Handle status event for this resource (or it's parent).""" """Handle status event for this resource (or it's parent)."""
if event_type == EventType.RESOURCE_DELETED: if event_type == EventType.RESOURCE_DELETED:
# handle removal of room and zone 'virtual' devices/services
# 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(
identifiers={(DOMAIN, resource.id)}
):
dev_reg.async_remove_device(device.id)
# cleanup entities that are not strictly device-bound and have the bridge as parent # cleanup entities that are not strictly device-bound and have the bridge as parent
if self.device is None: if self.device is None and resource.id == self.resource.id:
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
self.logger.debug("Received status update for %s", self.entity_id) self.logger.debug("Received status update for %s", self.entity_id)
self._check_availability() self._check_availability()
self.on_update() self.on_update()

View File

@ -1,6 +1,7 @@
"""Support for Hue groups (room/zone).""" """Support for Hue groups (room/zone)."""
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
from aiohue.v2 import HueBridgeV2 from aiohue.v2 import HueBridgeV2
@ -17,11 +18,12 @@ from homeassistant.components.light import (
FLASH_SHORT, FLASH_SHORT,
ColorMode, ColorMode,
LightEntity, LightEntity,
LightEntityDescription,
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from ..bridge import HueBridge from ..bridge import HueBridge
@ -43,18 +45,26 @@ async def async_setup_entry(
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
api: HueBridgeV2 = bridge.api api: HueBridgeV2 = bridge.api
@callback async def async_add_light(event_type: EventType, resource: GroupedLight) -> None:
def async_add_light(event_type: EventType, resource: GroupedLight) -> None:
"""Add Grouped Light for Hue Room/Zone.""" """Add Grouped Light for Hue Room/Zone."""
group = api.groups.grouped_light.get_zone(resource.id) # delay group creation a bit due to a race condition where the
# grouped_light resource is created before the zone/room
retries = 5
while (
retries
and (group := api.groups.grouped_light.get_zone(resource.id)) is None
):
retries -= 1
await asyncio.sleep(0.5)
if group is None: if group is None:
# guard, just in case
return return
light = GroupedHueLight(bridge, resource, group) light = GroupedHueLight(bridge, resource, group)
async_add_entities([light]) async_add_entities([light])
# add current items # add current items
for item in api.groups.grouped_light.items: for item in api.groups.grouped_light.items:
async_add_light(EventType.RESOURCE_ADDED, item) await async_add_light(EventType.RESOURCE_ADDED, item)
# register listener for new grouped_light # register listener for new grouped_light
config_entry.async_on_unload( config_entry.async_on_unload(
@ -67,7 +77,12 @@ async def async_setup_entry(
class GroupedHueLight(HueBaseEntity, LightEntity): class GroupedHueLight(HueBaseEntity, LightEntity):
"""Representation of a Grouped Hue light.""" """Representation of a Grouped Hue light."""
_attr_icon = "mdi:lightbulb-group" entity_description = LightEntityDescription(
key="hue_grouped_light",
icon="mdi:lightbulb-group",
has_entity_name=True,
name=None,
)
def __init__( def __init__(
self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone
@ -81,7 +96,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
self.api: HueBridgeV2 = bridge.api self.api: HueBridgeV2 = bridge.api
self._attr_supported_features |= LightEntityFeature.FLASH self._attr_supported_features |= LightEntityFeature.FLASH
self._attr_supported_features |= LightEntityFeature.TRANSITION self._attr_supported_features |= LightEntityFeature.TRANSITION
# we create a virtual service/device for Hue zones/rooms
# so we have a parent for grouped lights and scenes
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.group.id)},
)
self._dynamic_mode_active = False self._dynamic_mode_active = False
self._update_values() self._update_values()
@ -103,11 +122,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
self.api.lights.subscribe(self._handle_event, light_ids) self.api.lights.subscribe(self._handle_event, light_ids)
) )
@property
def name(self) -> str:
"""Return name of room/zone for this grouped light."""
return self.group.metadata.name
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if light is on.""" """Return true if light is on."""
@ -131,22 +145,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
"dynamics": self._dynamic_mode_active, "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: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the grouped_light on.""" """Turn the grouped_light on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))

View File

@ -19,6 +19,7 @@ from homeassistant.components.light import (
FLASH_SHORT, FLASH_SHORT,
ColorMode, ColorMode,
LightEntity, LightEntity,
LightEntityDescription,
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -69,6 +70,10 @@ async def async_setup_entry(
class HueLight(HueBaseEntity, LightEntity): class HueLight(HueBaseEntity, LightEntity):
"""Representation of a Hue light.""" """Representation of a Hue light."""
entity_description = LightEntityDescription(
key="hue_light", has_entity_name=True, name=None
)
def __init__( def __init__(
self, bridge: HueBridge, controller: LightsController, resource: Light self, bridge: HueBridge, controller: LightsController, resource: Light
) -> None: ) -> None:

View File

@ -20,6 +20,7 @@ from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -93,9 +94,13 @@ class HueSensorBase(HueBaseEntity, SensorEntity):
class HueTemperatureSensor(HueSensorBase): class HueTemperatureSensor(HueSensorBase):
"""Representation of a Hue Temperature sensor.""" """Representation of a Hue Temperature sensor."""
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS entity_description = SensorEntityDescription(
_attr_device_class = SensorDeviceClass.TEMPERATURE key="temperature_sensor",
_attr_state_class = SensorStateClass.MEASUREMENT device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
has_entity_name=True,
state_class=SensorStateClass.MEASUREMENT,
)
@property @property
def native_value(self) -> float: def native_value(self) -> float:
@ -106,9 +111,13 @@ class HueTemperatureSensor(HueSensorBase):
class HueLightLevelSensor(HueSensorBase): class HueLightLevelSensor(HueSensorBase):
"""Representation of a Hue LightLevel (illuminance) sensor.""" """Representation of a Hue LightLevel (illuminance) sensor."""
_attr_native_unit_of_measurement = LIGHT_LUX entity_description = SensorEntityDescription(
_attr_device_class = SensorDeviceClass.ILLUMINANCE key="lightlevel_sensor",
_attr_state_class = SensorStateClass.MEASUREMENT device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
has_entity_name=True,
state_class=SensorStateClass.MEASUREMENT,
)
@property @property
def native_value(self) -> int: def native_value(self) -> int:
@ -130,10 +139,14 @@ class HueLightLevelSensor(HueSensorBase):
class HueBatterySensor(HueSensorBase): class HueBatterySensor(HueSensorBase):
"""Representation of a Hue Battery sensor.""" """Representation of a Hue Battery sensor."""
_attr_native_unit_of_measurement = PERCENTAGE entity_description = SensorEntityDescription(
_attr_device_class = SensorDeviceClass.BATTERY key="battery_sensor",
_attr_entity_category = EntityCategory.DIAGNOSTIC device_class=SensorDeviceClass.BATTERY,
_attr_state_class = SensorStateClass.MEASUREMENT native_unit_of_measurement=PERCENTAGE,
has_entity_name=True,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
)
@property @property
def native_value(self) -> int: def native_value(self) -> int:
@ -151,16 +164,20 @@ class HueBatterySensor(HueSensorBase):
class HueZigbeeConnectivitySensor(HueSensorBase): class HueZigbeeConnectivitySensor(HueSensorBase):
"""Representation of a Hue ZigbeeConnectivity sensor.""" """Representation of a Hue ZigbeeConnectivity sensor."""
_attr_entity_category = EntityCategory.DIAGNOSTIC entity_description = SensorEntityDescription(
_attr_translation_key = "zigbee_connectivity" key="zigbee_connectivity_sensor",
_attr_device_class = SensorDeviceClass.ENUM device_class=SensorDeviceClass.ENUM,
_attr_options = [ has_entity_name=True,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="zigbee_connectivity",
options=[
"connected", "connected",
"disconnected", "disconnected",
"connectivity_issue", "connectivity_issue",
"unidirectional_incoming", "unidirectional_incoming",
] ],
_attr_entity_registry_enabled_default = False entity_registry_enabled_default=False,
)
@property @property
def native_value(self) -> str: def native_value(self) -> str:

View File

@ -3,6 +3,7 @@ import asyncio
from collections import deque from collections import deque
import json import json
import logging import logging
from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import aiohue.v1 as aiohue_v1 import aiohue.v1 as aiohue_v1
@ -12,6 +13,7 @@ import pytest
from homeassistant.components import hue from homeassistant.components import hue
from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base
from homeassistant.components.hue.v2.device import async_setup_devices
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import ( from tests.common import (
@ -20,6 +22,7 @@ from tests.common import (
load_fixture, load_fixture,
mock_device_registry, mock_device_registry,
) )
from tests.components.hue.const import FAKE_BRIDGE, FAKE_BRIDGE_DEVICE
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -56,6 +59,8 @@ def create_mock_bridge(hass, api_version=1):
async def async_initialize_bridge(): async def async_initialize_bridge():
if bridge.config_entry: if bridge.config_entry:
hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge
if bridge.api_version == 2:
await async_setup_devices(bridge)
return True return True
bridge.async_initialize_bridge = async_initialize_bridge bridge.async_initialize_bridge = async_initialize_bridge
@ -140,22 +145,10 @@ def create_mock_api_v2(hass):
"""Create a mock V2 API.""" """Create a mock V2 API."""
api = Mock(spec=aiohue_v2.HueBridgeV2) api = Mock(spec=aiohue_v2.HueBridgeV2)
api.initialize = AsyncMock() api.initialize = AsyncMock()
api.config = Mock(
bridge_id="aabbccddeeffggh",
mac_address="00:17:88:01:aa:bb:fd:c7",
model_id="BSB002",
api_version="9.9.9",
software_version="1935144040",
bridge_device=Mock(
id="4a507550-8742-4087-8bf5-c2334f29891c",
product_data=Mock(manufacturer_name="Mock"),
),
spec=aiohue_v2.ConfigController,
)
api.config.name = "Home"
api.mock_requests = [] api.mock_requests = []
api.logger = logging.getLogger(__name__) api.logger = logging.getLogger(__name__)
api.config = aiohue_v2.ConfigController(api)
api.events = aiohue_v2.EventStream(api) api.events = aiohue_v2.EventStream(api)
api.devices = aiohue_v2.DevicesController(api) api.devices = aiohue_v2.DevicesController(api)
api.lights = aiohue_v2.LightsController(api) api.lights = aiohue_v2.LightsController(api)
@ -171,9 +164,13 @@ def create_mock_api_v2(hass):
api.request = mock_request api.request = mock_request
async def load_test_data(data): async def load_test_data(data: list[dict[str, Any]]):
"""Load test data into controllers.""" """Load test data into controllers."""
api.config = aiohue_v2.ConfigController(api)
# append default bridge if none explicitly given in test data
if not any(x for x in data if x["type"] == "bridge"):
data.append(FAKE_BRIDGE)
data.append(FAKE_BRIDGE_DEVICE)
await asyncio.gather( await asyncio.gather(
api.config.initialize(data), api.config.initialize(data),

View File

@ -1,5 +1,29 @@
"""Constants for Hue tests.""" """Constants for Hue tests."""
FAKE_BRIDGE = {
"bridge_id": "aabbccddeeffggh",
"id": "07dd5849-abcd-efgh-b9b9-eb540408ce00",
"id_v1": "",
"owner": {"rid": "4a507550-8742-4087-8bf5-c2334f29891c", "rtype": "device"},
"time_zone": {"time_zone": "Europe/Amsterdam"},
"type": "bridge",
}
FAKE_BRIDGE_DEVICE = {
"id": "4a507550-8742-4087-8bf5-c2334f29891c",
"id_v1": "",
"metadata": {"archetype": "bridge_v2", "name": "Philips hue"},
"product_data": {
"certified": True,
"manufacturer_name": "Signify Netherlands B.V.",
"model_id": "BSB002",
"product_archetype": "bridge_v2",
"product_name": "Philips hue",
"software_version": "1.50.1950111030",
},
"services": [{"rid": "07dd5849-abcd-efgh-b9b9-eb540408ce00", "rtype": "bridge"}],
"type": "device",
}
FAKE_DEVICE = { FAKE_DEVICE = {
"id": "fake_device_id_1", "id": "fake_device_id_1",

View File

@ -25,19 +25,17 @@ async def test_binary_sensors(
assert sensor.attributes["device_class"] == "motion" assert sensor.attributes["device_class"] == "motion"
# test entertainment room active sensor # test entertainment room active sensor
sensor = hass.states.get( sensor = hass.states.get("binary_sensor.entertainmentroom_1")
"binary_sensor.entertainmentroom_1_entertainment_configuration"
)
assert sensor is not None assert sensor is not None
assert sensor.state == "off" assert sensor.state == "off"
assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" assert sensor.name == "Entertainmentroom 1"
assert sensor.attributes["device_class"] == "running" assert sensor.attributes["device_class"] == "running"
# test contact sensor # test contact sensor
sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") sensor = hass.states.get("binary_sensor.test_contact_sensor_opening")
assert sensor is not None assert sensor is not None
assert sensor.state == "off" assert sensor.state == "off"
assert sensor.name == "Test contact sensor Contact" assert sensor.name == "Test contact sensor Opening"
assert sensor.attributes["device_class"] == "opening" assert sensor.attributes["device_class"] == "opening"
# test contact sensor disabled == state unknown # test contact sensor disabled == state unknown
mock_bridge_v2.api.emit_event( mock_bridge_v2.api.emit_event(
@ -49,7 +47,7 @@ async def test_binary_sensors(
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") sensor = hass.states.get("binary_sensor.test_contact_sensor_opening")
assert sensor.state == "unknown" assert sensor.state == "unknown"
# test tamper sensor # test tamper sensor

View File

@ -57,7 +57,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None:
await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY])
await setup_platform(hass, mock_bridge_v2, "event") await setup_platform(hass, mock_bridge_v2, "event")
test_entity_id = "event.hue_mocked_device_relative_rotary" test_entity_id = "event.hue_mocked_device_rotary"
# verify entity does not exist before we start # verify entity does not exist before we start
assert hass.states.get(test_entity_id) is None assert hass.states.get(test_entity_id) is None
@ -70,7 +70,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None:
state = hass.states.get(test_entity_id) state = hass.states.get(test_entity_id)
assert state is not None assert state is not None
assert state.state == "unknown" assert state.state == "unknown"
assert state.name == "Hue mocked device Relative Rotary" assert state.name == "Hue mocked device Rotary"
# check event_types # check event_types
assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"] assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"]

View File

@ -186,7 +186,7 @@ async def test_scene_updates(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
test_entity = hass.states.get(test_entity_id) test_entity = hass.states.get(test_entity_id)
assert test_entity.name == "Test Room 2 Mocked Scene" assert test_entity.attributes["group_name"] == "Test Room 2"
# # test delete # # test delete
mock_bridge_v2.api.emit_event("delete", updated_resource) mock_bridge_v2.api.emit_event("delete", updated_resource)

View File

@ -18,9 +18,9 @@ async def test_switch(
assert len(hass.states.async_all()) == 4 assert len(hass.states.async_all()) == 4
# test config switch to enable/disable motion sensor # test config switch to enable/disable motion sensor
test_entity = hass.states.get("switch.hue_motion_sensor_motion") test_entity = hass.states.get("switch.hue_motion_sensor_motion_sensor_enabled")
assert test_entity is not None assert test_entity is not None
assert test_entity.name == "Hue motion sensor Motion" assert test_entity.name == "Hue motion sensor Motion sensor enabled"
assert test_entity.state == "on" assert test_entity.state == "on"
assert test_entity.attributes["device_class"] == "switch" assert test_entity.attributes["device_class"] == "switch"
@ -40,7 +40,7 @@ async def test_switch_turn_on_service(
await setup_platform(hass, mock_bridge_v2, "switch") await setup_platform(hass, mock_bridge_v2, "switch")
test_entity_id = "switch.hue_motion_sensor_motion" test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled"
# call the HA turn_on service # call the HA turn_on service
await hass.services.async_call( await hass.services.async_call(
@ -64,7 +64,7 @@ async def test_switch_turn_off_service(
await setup_platform(hass, mock_bridge_v2, "switch") await setup_platform(hass, mock_bridge_v2, "switch")
test_entity_id = "switch.hue_motion_sensor_motion" test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled"
# verify the switch is on before we start # verify the switch is on before we start
assert hass.states.get(test_entity_id).state == "on" assert hass.states.get(test_entity_id).state == "on"
@ -103,7 +103,7 @@ async def test_switch_added(hass: HomeAssistant, mock_bridge_v2) -> None:
await setup_platform(hass, mock_bridge_v2, "switch") await setup_platform(hass, mock_bridge_v2, "switch")
test_entity_id = "switch.hue_mocked_device_motion" test_entity_id = "switch.hue_mocked_device_motion_sensor_enabled"
# verify entity does not exist before we start # verify entity does not exist before we start
assert hass.states.get(test_entity_id) is None assert hass.states.get(test_entity_id) is None