diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index caf4ca0008c6..e336da147a9c 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -28,12 +28,12 @@ from homeassistant.components.switch import ( from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN from .v2.entity import HueBaseEntity +from .v2.helpers import get_motion_area_device_info async def async_setup_entry( @@ -229,11 +229,8 @@ class HueMotionAreaConfigurationEnabledEntity(HueResourceEnabledEntity): ) -> None: """Initialize the switch.""" super().__init__(bridge, controller, resource) - # link the switch to the group the MotionAware zone is associated with - self.hue_group = controller.get_group(resource.id) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.hue_group.id)}, - ) + # link the switch to the room or zone the MotionAware zone is associated with + self._attr_device_info = get_motion_area_device_info(bridge.api, resource) class HueMotionSensorEnabledEntity(HueResourceEnabledEntity): diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 07806f909493..b1d02c85d11a 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -41,6 +41,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity +from .helpers import get_motion_area_device_info type SensorType = ( CameraMotion @@ -230,14 +231,12 @@ class HueMotionAwareSensor(HueMotionSensor): ) -> 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.hue_group = self.bridge.api.groups[group_id] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.hue_group.id)}, + # link the MotionAware sensor to the room or zone it is associated with + self._attr_device_info = get_motion_area_device_info( + self.bridge.api, self._motion_area_configuration ) @override diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index dd0c2c97f911..10613eb029a0 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -1,7 +1,13 @@ """Helper functions for Philips Hue v2.""" +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.models.motion_area_configuration import MotionAreaConfiguration + +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import color as color_util +from ..const import DOMAIN + def normalize_hue_brightness(brightness: float | None) -> float | None: """Return calculated brightness values.""" @@ -30,3 +36,17 @@ def normalize_hue_colortemp( colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k) # Hue only accepts a range between min_mireds..max_mireds return min(max(colortemp_mireds, min_mireds), max_mireds) + + +def get_motion_area_device_info( + api: HueBridgeV2, motion_area: MotionAreaConfiguration +) -> DeviceInfo: + """Return the device the entities of a MotionAware zone belong to. + + Rooms and zones each have a device of their own. A MotionAware zone that + covers the whole home points at `bridge_home` instead, which has none, so + its entities are attached to the bridge. + """ + if (group := api.groups.get(motion_area.group.rid)) is None: + return DeviceInfo(identifiers={(DOMAIN, api.config.bridge_id)}) + return DeviceInfo(identifiers={(DOMAIN, group.id)}) diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 26a379a5ac97..2f16d88580c2 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -147,6 +147,16 @@ def v2_resources_test_data() -> JsonArrayType: return load_json_array_fixture("hue/v2_resources.json") +def replace_resources( + data: JsonArrayType, resources: list[dict[str, Any]] +) -> JsonArrayType: + """Return the test data with each resource of the same id replaced.""" + replacements = {resource["id"]: resource for resource in resources} + missing = replacements.keys() - {resource["id"] for resource in data} + assert not missing, f"resource id(s) not present in the test data: {missing}" + return [replacements.get(resource["id"], resource) for resource in data] + + def create_mock_api_v2() -> Mock: """Create a mock V2 API.""" api = Mock(spec=aiohue_v2.HueBridgeV2) diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 32fa0df31365..902c068bd8ca 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -5,15 +5,24 @@ from unittest.mock import Mock import pytest +from homeassistant.components.hue.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.json import JsonArrayType -from .conftest import setup_platform -from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY +from .conftest import replace_resources, setup_platform +from .const import ( + FAKE_BINARY_SENSOR, + FAKE_BRIDGE, + FAKE_DEVICE, + FAKE_ZIGBEE_CONNECTIVITY, +) MOTION_AWARE_ENTITY_ID = "binary_sensor.test_room_test_room_motion_aware_sensor_1" MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" +TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +BRIDGE_HOME_ID = "a3fbc86a-bf4c-4c69-899d-d6eafc37e288" AREA_MOTION_SERVICE_IDS = { "convenience_area_motion": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", "security_area_motion": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", @@ -57,16 +66,6 @@ def area_motion_service( return service -def replace_resources( - data: JsonArrayType, resources: list[dict[str, Any]] -) -> JsonArrayType: - """Return the test data with each resource of the same id replaced.""" - replacements = {resource["id"]: resource for resource in resources} - missing = replacements.keys() - {resource["id"] for resource in data} - assert not missing, f"resource id(s) not present in the test data: {missing}" - return [replacements.get(resource["id"], resource) for resource in data] - - async def test_binary_sensors( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: @@ -514,3 +513,57 @@ async def test_motion_aware_sensor_zone_not_reporting( ) await hass.async_block_till_done() assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" + + +@pytest.mark.parametrize( + ("group", "device_identifier"), + [ + pytest.param( + {"rid": TEST_ROOM_ID, "rtype": "room"}, + (DOMAIN, TEST_ROOM_ID), + id="room", + ), + pytest.param( + {"rid": BRIDGE_HOME_ID, "rtype": "bridge_home"}, + (DOMAIN, FAKE_BRIDGE["bridge_id"]), + id="whole_home", + ), + ], +) +async def test_motion_aware_sensor_device( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + group: dict[str, str], + device_identifier: tuple[str, str], +) -> None: + """Test the MotionAware sensor is attached to its room or zone, or to the bridge. + + A MotionAware zone that covers the whole home points at `bridge_home`, which + has no device of its own. + """ + motion_area_configuration = next( + resource + for resource in v2_resources_test_data + if resource["id"] == MOTION_AREA_CONFIGURATION_ID + ) + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, [{**motion_area_configuration, "group": group}] + ) + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + entity_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, AREA_MOTION_SERVICE_IDS["security_area_motion"] + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == "off" + + device = device_registry.async_get_device_by_identifier( + device_identifier, mock_bridge_v2.config_entry.entry_id + ) + assert device is not None + assert entity_registry.async_get(entity_id).device_id == device.id diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index 66e8e7389440..412c70fb111c 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -10,11 +10,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.json import JsonArrayType -from .conftest import setup_platform +from .conftest import replace_resources, setup_platform from .const import ( FAKE_BEHAVIOR_INSTANCE, FAKE_BEHAVIOR_SCRIPT, FAKE_BINARY_SENSOR, + FAKE_BRIDGE, FAKE_DEVICE, FAKE_PRESENCE_MIMICKING_INSTANCE, FAKE_PRESENCE_MIMICKING_SCRIPT, @@ -22,6 +23,8 @@ from .const import ( ) TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +BRIDGE_HOME_ID = "a3fbc86a-bf4c-4c69-899d-d6eafc37e288" +MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" async def test_switch( @@ -59,26 +62,58 @@ async def test_switch( assert test_entity.attributes["device_class"] == "switch" +@pytest.mark.parametrize( + ("group", "device_identifier"), + [ + pytest.param( + {"rid": TEST_ROOM_ID, "rtype": "room"}, + (DOMAIN, TEST_ROOM_ID), + id="room", + ), + pytest.param( + {"rid": BRIDGE_HOME_ID, "rtype": "bridge_home"}, + (DOMAIN, FAKE_BRIDGE["bridge_id"]), + id="whole_home", + ), + ], +) async def test_motionaware_switch_device( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + group: dict[str, str], + device_identifier: tuple[str, str], ) -> None: - """Test the MotionAware switch is attached to the zone device, not the bridge.""" - await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + """Test the MotionAware switch is attached to its room or zone, or to the bridge. + A MotionAware zone that covers the whole home points at `bridge_home`, which + has no device of its own. + """ + motion_area_configuration = next( + resource + for resource in v2_resources_test_data + if resource["id"] == MOTION_AREA_CONFIGURATION_ID + ) + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, [{**motion_area_configuration, "group": group}] + ) + ) await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) - entity_entry = entity_registry.async_get("switch.test_room_test_room_motionaware") - assert entity_entry is not None - - zone_device = device_registry.async_get_device_by_identifier( - (DOMAIN, TEST_ROOM_ID), mock_bridge_v2.config_entry.entry_id + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, MOTION_AREA_CONFIGURATION_ID ) - assert zone_device is not None - assert entity_entry.device_id == zone_device.id + assert entity_id is not None + assert hass.states.get(entity_id).state == "on" + + device = device_registry.async_get_device_by_identifier( + device_identifier, mock_bridge_v2.config_entry.entry_id + ) + assert device is not None + assert entity_registry.async_get(entity_id).device_id == device.id async def test_switch_turn_on_service( @@ -172,14 +207,13 @@ async def test_motionaware_switch_turn_on_off_service( assert mock_bridge_v2.mock_requests[0]["method"] == "put" assert ( mock_bridge_v2.mock_requests[0]["path"] - == "clip/v2/resource/motion_area_configuration/" - "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" + == f"clip/v2/resource/motion_area_configuration/{MOTION_AREA_CONFIGURATION_ID}" ) assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is False # Now generate update event by emitting the json we've sent as incoming event event = { - "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "id": MOTION_AREA_CONFIGURATION_ID, "type": "motion_area_configuration", **mock_bridge_v2.mock_requests[0]["json"], }