From 2008a73657a2572f28c275fc5ef6f9906db03ff7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 22:52:09 +0200 Subject: [PATCH] Add support for Hue MotionAware sensors (#152811) Co-authored-by: Franck Nijhof --- homeassistant/components/hue/event.py | 31 ++- .../components/hue/v2/binary_sensor.py | 131 +++++++++++- homeassistant/components/hue/v2/device.py | 11 +- homeassistant/components/hue/v2/sensor.py | 69 ++++++- .../components/hue/fixtures/v2_resources.json | 194 ++++++++++++++++++ tests/components/hue/test_binary_sensor.py | 98 ++++++++- tests/components/hue/test_event.py | 4 +- tests/components/hue/test_sensor_v2.py | 50 ++++- 8 files changed, 569 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 4cffbb73a38..c13cccd48e6 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -6,6 +6,7 @@ from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.bell_button import BellButton from aiohue.v2.models.button import Button from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection @@ -39,19 +40,27 @@ async def async_setup_entry( @callback def async_add_entity( event_type: EventType, - resource: Button | RelativeRotary, + resource: Button | RelativeRotary | BellButton, ) -> None: """Add entity from Hue resource.""" if isinstance(resource, RelativeRotary): async_add_entities( [HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)] ) + elif isinstance(resource, BellButton): + async_add_entities( + [HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)] + ) else: async_add_entities( [HueButtonEventEntity(bridge, api.sensors.button, resource)] ) - for controller in (api.sensors.button, api.sensors.relative_rotary): + for controller in ( + api.sensors.button, + api.sensors.relative_rotary, + api.sensors.bell_button, + ): # add all current items in controller for item in controller: async_add_entity(EventType.RESOURCE_ADDED, item) @@ -67,6 +76,8 @@ async def async_setup_entry( class HueButtonEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a button resource.""" + resource: Button | BellButton + entity_description = EventEntityDescription( key="button", device_class=EventDeviceClass.BUTTON, @@ -91,7 +102,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): } @callback - def _handle_event(self, event_type: EventType, resource: Button) -> None: + def _handle_event( + self, event_type: EventType, resource: Button | BellButton + ) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: if resource.button is None or resource.button.button_report is None: @@ -102,6 +115,18 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): super()._handle_event(event_type, resource) +class HueBellButtonEventEntity(HueButtonEventEntity): + """Representation of a Hue Event entity from a bell_button resource.""" + + resource: Button | BellButton + + entity_description = EventEntityDescription( + key="bell_button", + device_class=EventDeviceClass.DOORBELL, + has_entity_name=True, + ) + + class HueRotaryEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a RelativeRotary resource.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 17584a0f5cb..da28fd1f6a9 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -13,13 +13,18 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( CameraMotionController, ContactController, + GroupedMotionController, MotionController, + SecurityAreaMotionController, TamperController, ) from aiohue.v2.models.camera_motion import CameraMotion from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus +from aiohue.v2.models.grouped_motion import GroupedMotion from aiohue.v2.models.motion import Motion +from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.security_area_motion import SecurityAreaMotion from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( @@ -29,21 +34,54 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueConfigEntry +from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +type SensorType = ( + CameraMotion + | Contact + | Motion + | EntertainmentConfiguration + | Tamper + | GroupedMotion + | SecurityAreaMotion +) type ControllerType = ( CameraMotionController | ContactController | MotionController | EntertainmentConfigurationController | TamperController + | GroupedMotionController + | SecurityAreaMotionController ) +def _resource_valid(resource: SensorType, controller: ControllerType) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedMotion): + # filter out GroupedMotion sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedMotion without parent (should not happen, but just in case) + if not (parent := controller.get_parent(resource.id)): + return False + # filter out GroupedMotion sensors that have only one member, because Hue creates one + # default grouped Motion sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -59,11 +97,17 @@ async def async_setup_entry( @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: - """Add Hue Binary Sensor.""" + """Add Hue Binary Sensor from resource added callback.""" + if not _resource_valid(resource, controller): + return async_add_entities([make_binary_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_binary_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller) + ) # register listener for new sensors config_entry.async_on_unload( @@ -78,6 +122,8 @@ async def async_setup_entry( register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) register_items(api.sensors.contact, HueContactSensor) register_items(api.sensors.tamper, HueTamperSensor) + register_items(api.sensors.grouped_motion, HueGroupedMotionSensor) + register_items(api.sensors.security_area_motion, HueMotionAwareSensor) # pylint: disable-next=hass-enforce-class-module @@ -102,6 +148,83 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity): return self.resource.motion.value +# pylint: disable-next=hass-enforce-class-module +class HueGroupedMotionSensor(HueMotionSensor): + """Representation of a Hue Grouped Motion sensor.""" + + controller: GroupedMotionController + resource: GroupedMotion + + def __init__( + self, + bridge: HueBridge, + controller: GroupedMotionController, + resource: GroupedMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedMotion sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + parent = self.controller.get_parent(resource.id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + +# pylint: disable-next=hass-enforce-class-module +class HueMotionAwareSensor(HueMotionSensor): + """Representation of a Motion sensor based on Hue Motion Aware. + + Note that we only create sensors for the SecurityAreaMotion resource + and not for the ConvenienceAreaMotion resource, because the latter + does not have a state when it's not directly controlling lights. + The SecurityAreaMotion resource is always available with a state, allowing + Home Assistant users to actually use it as a motion sensor in their HA automations. + """ + + controller: SecurityAreaMotionController + resource: SecurityAreaMotion + + entity_description = BinarySensorEntityDescription( + key="motion_sensor", + device_class=BinarySensorDeviceClass.MOTION, + has_entity_name=False, + ) + + @property + def name(self) -> str: + """Return sensor name.""" + return self.controller.get_motion_area_configuration(self.resource.id).name + + def __init__( + self, + bridge: HueBridge, + controller: SecurityAreaMotionController, + resource: SecurityAreaMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the MotionAware sensor to the group the sensor is associated with + self._motion_area_configuration = self.controller.get_motion_area_configuration( + resource.id + ) + group_id = self._motion_area_configuration.group.rid + self.group = self.bridge.api.groups[group_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + # subscribe to updates of the MotionAreaConfiguration to update the name + self.async_on_remove( + self.bridge.api.config.subscribe( + self._handle_event, self._motion_area_configuration.id + ) + ) + + # pylint: disable-next=hass-enforce-class-module class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 62dbe940217..e6bded7a7f7 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -9,6 +9,7 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.groups import Room, Zone from aiohue.v2.models.device import Device from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.service_group import ServiceGroup from homeassistant.const import ( ATTR_CONNECTIONS, @@ -39,16 +40,16 @@ async def async_setup_devices(bridge: HueBridge): dev_controller = api.devices @callback - def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: + def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry: """Register a Hue device in device registry.""" - if isinstance(hue_resource, (Room, Zone)): + if isinstance(hue_resource, (Room, Zone, ServiceGroup)): # 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(), + model=hue_resource.type.value.replace("_", " ").title(), manufacturer=api.config.bridge_device.product_data.manufacturer_name, via_device=(DOMAIN, api.config.bridge_device.id), suggested_area=hue_resource.metadata.name @@ -85,7 +86,7 @@ async def async_setup_devices(bridge: HueBridge): @callback def handle_device_event( - evt_type: EventType, hue_resource: Device | Room | Zone + evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup ) -> None: """Handle event from Hue controller.""" if evt_type == EventType.RESOURCE_DELETED: @@ -101,6 +102,7 @@ async def async_setup_devices(bridge: HueBridge): known_devices = [add_device(hue_device) for hue_device in hue_devices] 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] + known_devices += [add_device(sg) for sg in api.config.service_group] # Check for nodes that no longer exist and remove them for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): @@ -111,3 +113,4 @@ async def async_setup_devices(bridge: HueBridge): 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)) + entry.async_on_unload(api.config.service_group.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 1eec4eaa6b9..0c92b0c8b3e 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -9,13 +9,16 @@ from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( DevicePowerController, + GroupedLightLevelController, LightLevelController, SensorsController, TemperatureController, ZigbeeConnectivityController, ) from aiohue.v2.models.device_power import DevicePower +from aiohue.v2.models.grouped_light_level import GroupedLightLevel from aiohue.v2.models.light_level import LightLevel +from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.temperature import Temperature from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity @@ -27,20 +30,50 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity +type SensorType = ( + DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel +) type ControllerType = ( DevicePowerController | LightLevelController | TemperatureController | ZigbeeConnectivityController + | GroupedLightLevelController ) +def _resource_valid( + resource: SensorType, controller: ControllerType, api: HueBridgeV2 +) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedLightLevel): + # filter out GroupedLightLevel sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedLightLevel without parent (should not happen, but just in case) + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + if not parent: + return False + # filter out GroupedLightLevel sensors that have only one member, because Hue creates one + # default grouped LightLevel sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -58,10 +91,16 @@ async def async_setup_entry( @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Sensor.""" + if not _resource_valid(resource, controller, api): + return async_add_entities([make_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller, api) + ) # register listener for new sensors config_entry.async_on_unload( @@ -75,6 +114,7 @@ async def async_setup_entry( register_items(ctrl_base.light_level, HueLightLevelSensor) register_items(ctrl_base.device_power, HueBatterySensor) register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) + register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor) # pylint: disable-next=hass-enforce-class-module @@ -140,6 +180,31 @@ class HueLightLevelSensor(HueSensorBase): } +# pylint: disable-next=hass-enforce-class-module +class HueGroupedLightLevelSensor(HueLightLevelSensor): + """Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource.""" + + controller: GroupedLightLevelController + resource: GroupedLightLevel + + def __init__( + self, + bridge: HueBridge, + controller: GroupedLightLevelController, + resource: GroupedLightLevel, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedLightLevel sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + api = self.bridge.api + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + # pylint: disable-next=hass-enforce-class-module class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 3d718f24c50..321ffa20508 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2363,5 +2363,199 @@ "sensitivity_max": 4 }, "type": "motion" + }, + { + "id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T08:13:42.394Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "convenience_area_motion" + }, + { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T05:54:08.166Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "security_area_motion" + }, + { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "name": "Motion Aware Sensor 1", + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "participants": [ + { + "resource": { + "rid": "a17253ed-168d-471a-8e59-01a101441511", + "rtype": "motion_area_candidate" + }, + "status": { + "health": "healthy" + } + } + ], + "services": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "rtype": "security_area_motion" + } + ], + "health": "healthy", + "enabled": true, + "type": "motion_area_configuration" + }, + { + "id": "9f8e7d6c-5b4a-3e2d-1c0b-9a8f7e6d5c4b", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "state": "no_update", + "problems": [], + "type": "device_software_update" + }, + { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "light": { + "light_level_report": { + "changed": "2023-09-23T06:19:38.865Z", + "light_level": 0 + } + }, + "type": "grouped_light_level" + }, + { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "motion": { + "motion_report": { + "changed": "2023-09-23T08:20:51.384Z", + "motion": false + } + }, + "type": "grouped_motion" + }, + { + "id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + "id_v1": "/sensors/75", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 30, + "duration": 400 + } + }, + "rotary_report": { + "updated": "2023-09-21T10:00:03.276Z", + "action": "start", + "rotation": { + "direction": "counter_clock_wise", + "steps": 45, + "duration": 400 + } + } + }, + "type": "relative_rotary" + }, + { + "id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "children": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "5f317b69-9da0-4b4f-84f2-7ca07b9fe346", + "rtype": "security_area_motion" + } + ], + "services": [ + { + "rid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "rtype": "grouped_motion" + }, + { + "rid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "rtype": "grouped_light_level" + } + ], + "metadata": { + "name": "Sensor group" + }, + "type": "service_group" + }, + { + "id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", + "name": "Test clip resource", + "type": "clip" + }, + { + "id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + "type": "matter", + "enabled": true, + "max_fabrics": 5, + "has_qr_code": false + }, + { + "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", + "time": { + "time_zone": "UTC", + "time": "2023-09-23T10:30:00Z" + }, + "type": "time" + }, + { + "id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e", + "status": "ready", + "type": "zigbee_device_discovery" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index b9c21a5231f..02b4d93acfe 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -19,8 +19,7 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 5 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 5 + # 7 binary_sensors should be created from test data # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -81,6 +80,20 @@ async def test_binary_sensors( assert sensor.name == "Test Camera Motion" assert sensor.attributes["device_class"] == "motion" + # test grouped motion sensor + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Sensor group Motion" + assert sensor.attributes["device_class"] == "motion" + + # test motion aware sensor + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Motion Aware Sensor 1" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update( hass: HomeAssistant, mock_bridge_v2: Mock @@ -110,3 +123,84 @@ async def test_binary_sensor_add_update( test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "on" + + +async def test_grouped_motion_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedMotionSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test grouped motion sensor exists and has correct state + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of grouped motion sensor works on incoming event + updated_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "motion": { + "motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True} + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "on" + + # test disabled grouped motion sensor == state unknown + disabled_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "enabled": False, + } + mock_bridge_v2.api.emit_event("update", disabled_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "unknown" + + +async def test_motion_aware_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueMotionAwareSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test motion aware sensor exists and has correct state + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of motion aware sensor works on incoming event + updated_sensor = { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "type": "security_area_motion", + "motion": { + "motion": True, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor.state == "on" + + # test name update when motion area configuration name changes + updated_config = { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "type": "motion_area_configuration", + "name": "Updated Motion Area", + } + mock_bridge_v2.api.emit_event("update", updated_config) + await hass.async_block_till_done() + # The entity name is derived from the motion area configuration name + # but the entity ID doesn't change - we just verify the sensor still exists + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.name == "Updated Motion Area" diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index 88b44165687..73ae1e5d1d5 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -17,8 +17,8 @@ async def test_event( """Test event entity for Hue integration.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, Platform.EVENT) - # 7 entities should be created from test data - assert len(hass.states.async_all()) == 7 + # 8 entities should be created from test data + assert len(hass.states.async_all()) == 8 # pick one of the remote buttons state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 7c5afae3371..e7b90c2015d 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -27,8 +27,8 @@ async def test_sensors( await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) # 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 - assert len(hass.states.async_all()) == 6 + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 # test temperature sensor sensor = hass.states.get("sensor.hue_motion_sensor_temperature") @@ -59,6 +59,16 @@ async def test_sensors( assert sensor.attributes["unit_of_measurement"] == "%" assert sensor.attributes["battery_state"] == "normal" + # test grouped light level sensor + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert sensor.state == "0" + assert sensor.attributes["friendly_name"] == "Sensor group Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 0 + # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" entity_entry = entity_registry.async_get(entity_id) @@ -139,3 +149,39 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2: Mock) -> N test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "22.5" + + +async def test_grouped_light_level_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedLightLevelSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) + + # test grouped light level sensor exists and has correct state + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert ( + sensor.state == "0" + ) # Light level 0 translates to 10^((0-1)/10000) ≈ 0 lux (rounded) + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["light_level"] == 0 + + # test update of grouped light level sensor works on incoming event + updated_sensor = { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "type": "grouped_light_level", + "light": { + "light_level": 30000, + "light_level_report": { + "changed": "2023-09-23T08:20:51.384Z", + "light_level": 30000, + }, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert ( + sensor.state == "999" + ) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux