From f1e44ab1258903fc83e94829f6de952fa32c08e3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Aug 2026 13:06:53 +0200 Subject: [PATCH] Add parameter include_composite_devices to DeviceRegistry.async_get (#179594) --- .../components/config/device_registry.py | 15 +- .../components/device_automation/helpers.py | 5 +- homeassistant/components/homekit/__init__.py | 24 +-- homeassistant/components/template/__init__.py | 9 +- homeassistant/components/template/entity.py | 10 +- homeassistant/components/template/repairs.py | 10 +- homeassistant/helpers/device_registry.py | 52 ++++-- homeassistant/helpers/entity_registry.py | 18 +- homeassistant/helpers/helper_integration.py | 6 +- homeassistant/helpers/service.py | 4 +- homeassistant/helpers/target.py | 19 ++- .../components/config/test_device_registry.py | 3 +- tests/helpers/test_device_registry.py | 155 +++++++++++++++++- 13 files changed, 262 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 462448c5536a..0a93b68cd532 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -180,7 +180,8 @@ def websocket_update_device( msg["labels"] = set(msg["labels"]) entry: dr.AnyDeviceEntry | None - if msg["device_id"] in registry.child_devices: + device = registry.async_get(msg["device_id"], include_composite_devices=False) + if isinstance(device, dr.ChildDeviceEntry): entry = registry.async_update_child_device(**msg) else: entry = registry.async_update_device(**msg) @@ -207,10 +208,16 @@ async def _async_remove_device( device_id = msg["device_id"] # A composite device id has no single underlying device to remove; reject it. - if registry.async_is_composite_device_id(device_id): + if ( + registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None + ): raise HomeAssistantError("Cannot remove a composite device") - - if (device_entry := registry.async_get(device_id)) is None: + if ( + device_entry := registry.async_get(device_id, include_composite_devices=False) + ) is None: raise HomeAssistantError("Unknown device") if ( diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index f7c5bfc32b5c..6b91465a7e42 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -53,7 +53,10 @@ def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: knows the current device id, not the removed composite id. """ device_registry = dr.async_get(hass) - if device_id in device_registry.devices: + if ( + device_registry.async_get(device_id, include_composite_devices=False) + is not None + ): return device_id if not ( split_devices := device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 38f206d36412..e2ce48571a85 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1071,9 +1071,17 @@ class HomeKit: dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: - if dev_reg.async_get(device_id, include_child_devices=False): - valid_device_ids.append(device_id) - elif dev_reg.async_get(device_id, include_main_devices=False): + device = dev_reg.async_get(device_id) + if device is None: + _LOGGER.warning( + ( + "HomeKit %s cannot add device %s because it is missing from the" + " device registry" + ), + self._name, + device_id, + ) + elif isinstance(device, dr.ChildDeviceEntry): _LOGGER.warning( ( "HomeKit %s cannot add device %s because a child device cannot" @@ -1083,14 +1091,8 @@ class HomeKit: device_id, ) else: - _LOGGER.warning( - ( - "HomeKit %s cannot add device %s because it is missing from the" - " device registry" - ), - self._name, - device_id, - ) + # A main or composite device is a valid HomeKit accessory + valid_device_ids.append(device_id) for device_id, device_triggers in ( await device_automation.async_get_device_automations( self.hass, diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 2052ab8da8da..99225a004265 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -102,8 +102,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_all_devices=True, ) - if device_id is not None and dr.async_get(hass).async_is_composite_device_id( - device_id + device_registry = dr.async_get(hass) + if ( + device_id is not None + and device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None ): # The device was split into one device per config entry; ask the user to # select a device again diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 85d19f013b1f..a2020a30a77c 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -89,11 +89,11 @@ class AbstractTemplateEntity(Entity): device_registry = dr.async_get(hass) # Allow linking to a main or child device, but not to a composite device. - if ( - (device_id := config.get(CONF_DEVICE_ID)) is not None - and (device_entry := device_registry.async_get(device_id)) is not None - and not device_registry.async_is_composite_device_id(device_id) - ): + if (device_id := config.get(CONF_DEVICE_ID)) is not None and ( + device_entry := device_registry.async_get( + device_id, include_composite_devices=False + ) + ) is not None: self.device_entry = device_entry @property diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py index c7e308eee879..3854c36a458d 100644 --- a/homeassistant/components/template/repairs.py +++ b/homeassistant/components/template/repairs.py @@ -41,9 +41,13 @@ class CompositeDeviceIdRepairFlow(RepairsFlow): errors: dict[str, str] = {} if user_input is not None: device_id = user_input.get(CONF_DEVICE_ID) - if device_id is None or ( - device_registry.async_get(device_id) is not None - and not device_registry.async_is_composite_device_id(device_id) + if ( + device_id is None + or device_registry.async_get( + device_id, + include_composite_devices=False, + ) + is not None ): options = {**entry.options} if device_id: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 53f07eefd82a..9ffb5c842bb3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1738,22 +1738,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): serialize_in_event_loop=False, ) - @overload - def async_get( - self, - device_id: str, - *, - include_child_devices: Literal[True] = True, - include_main_devices: Literal[True] = True, - ) -> AnyDeviceEntry | None: ... - @overload def async_get( self, device_id: str, *, include_child_devices: Literal[False], - include_main_devices: Literal[True] = True, + include_main_devices: bool = True, + include_composite_devices: bool = True, ) -> DeviceEntry | None: ... @overload @@ -1763,8 +1755,29 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): *, include_child_devices: Literal[True] = True, include_main_devices: Literal[False], + include_composite_devices: Literal[False], ) -> ChildDeviceEntry | None: ... + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], + include_composite_devices: Literal[True] = True, + ) -> AnyDeviceEntry | None: ... + + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, + include_composite_devices: bool = True, + ) -> AnyDeviceEntry | None: ... + @callback def async_get( self, @@ -1772,6 +1785,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): *, include_child_devices: bool = True, include_main_devices: bool = True, + include_composite_devices: bool = True, ) -> AnyDeviceEntry | None: """Get device or child device. @@ -1786,8 +1800,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): With include_child_devices=False a child-device id resolves to None (the child is treated as absent) and the return type excludes children. With - include_main_devices=False a main-device id (including a composite) resolves to - None and the return type excludes main devices. + include_main_devices=False a main-device id resolves to None. With + include_composite_devices=False a composite-device id resolves to None. """ if ( include_main_devices @@ -1799,7 +1813,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): and (child_device := self._child_device_data.get(device_id)) is not None ): return child_device - if include_main_devices and ( + if include_composite_devices and ( split_devices := self.devices.get_devices_for_composite_device_id(device_id) ): return self._restore_composite_device(device_id, split_devices) @@ -2021,6 +2035,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): composite device id no longer refers to a registered device. Returns False for a registered device id, and None for an unknown id. """ + report_usage( + "calls `device_registry.async_is_composite_device_id`, which is " + "deprecated; use `async_get` with `include_composite_devices=False` " + "instead - a composite device id resolves with `async_get(device_id)` but " + "not with `async_get(device_id, include_composite_devices=False)`", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + breaks_in_ha_version="2027.9.0", + ) if device_id in self.devices: return False if self.devices.get_devices_for_composite_device_id(device_id): @@ -2949,8 +2972,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if ( via_device_id is not UNDEFINED and via_device_id is not None - and via_device_id not in self.devices - and not self.devices.get_devices_for_composite_device_id(via_device_id) + and self.async_get(via_device_id, include_child_devices=False) is None ): if via_device_id in self._child_device_data: raise HomeAssistantError( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b411623ced37..1d9f4dce8341 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1168,8 +1168,8 @@ def _validate_item( if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if ( - device_id not in device_registry.devices - and device_id not in device_registry.child_devices + device_registry.async_get(device_id, include_composite_devices=False) + is None ): raise ValueError(f"Device {device_id} does not exist") if ( @@ -1815,7 +1815,12 @@ class EntityRegistry(BaseRegistry): if not device_id or device_id is UNDEFINED: return device_id device_registry = dr.async_get(self.hass) - if not device_registry.async_is_composite_device_id(device_id): + if ( + device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is None + ): # A real device or an unknown id; let _validate_item handle it return device_id report_issue = async_suggest_report_issue( @@ -2176,13 +2181,10 @@ class EntityRegistry(BaseRegistry): config_subentry_id: str | None, ) -> str | None: """Map a device id to the split device matching the entity's config entry.""" - # Note: check container membership, not async_get, which returns a restored - # composite for a composite device id. Child devices are their own container - # and are never composites, so an entity on one keeps its device id. if ( device_id is None - or device_id in device_registry.devices - or device_id in device_registry.child_devices + or device_registry.async_get(device_id, include_composite_devices=False) + is not None ): return device_id successors = device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c6ceb6508f68..1aaeaf75f584 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -172,7 +172,7 @@ def async_remove_helper_devices( if source_device_id is not None else None ) - if source_device is None: + if source_device_id is None or source_device is None: # No source device (gone, or none selected). In remove-all mode the helper's devices # are still removed, leaving its entities without a device; targeted mode has no # duplicate to match. @@ -190,8 +190,8 @@ def async_remove_helper_devices( # synthesized composite) or a concrete device - a main device or a child device. A main # device's splits, if any, share this id as their composite_device_id. source_is_concrete = ( - source_device_id in device_registry.devices - or source_device_id in device_registry.child_devices + device_registry.async_get(source_device_id, include_composite_devices=False) + is not None ) composite_device_id = ( ( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 192226e7a33c..cb878df223a5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -430,8 +430,8 @@ async def async_extract_config_entry_ids( # Some devices may have no entities for device_id in referenced.referenced_devices: - if (device_id in dev_reg.devices or device_id in dev_reg.child_devices) and ( - device := dev_reg.async_get(device_id) + if ( + device := dev_reg.async_get(device_id, include_composite_devices=False) ) is not None: config_entry_ids.update(device.config_entries) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 348b2da7bd94..bd9c062c03a3 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -161,15 +161,11 @@ def _resolve_referenced_devices( ) -> None: """Resolve targeted device ids into referenced device ids.""" for device_id in device_ids: - if device_id in dev_reg.devices: + device = dev_reg.async_get(device_id) + if device is None: + selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) - selected.referenced_devices.update( - child_device.id - for child_device in dev_reg.child_devices.get_children_for_device_id( - device_id - ) - ) - elif device_id in dev_reg.child_devices: + elif isinstance(device, dr.ChildDeviceEntry): selected.referenced_devices.add(device_id) elif split_devices := dev_reg.async_get_devices_for_composite_device_id( device_id @@ -190,8 +186,13 @@ def _resolve_referenced_devices( ) ) else: - selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) + selected.referenced_devices.update( + child_device.id + for child_device in dev_reg.child_devices.get_children_for_device_id( + device_id + ) + ) def async_extract_referenced_entity_ids( diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 02040d1b49ea..67acc84ee112 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -737,7 +737,8 @@ async def test_remove_device_composite( await dr.async_load(hass) # pylint: disable-next=home-assistant-tests-registry-fixtures registry = dr.async_get(hass) - assert registry.async_is_composite_device_id(composite_id) is True + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None response = await _send_remove_device( client, command, composite_id, entry_1.entry_id diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 068d98264f95..9eb621e68c4b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -48,10 +48,10 @@ def _downgrade_device_registry_deprecation_reports( ) -> Generator[None]: """Keep the deprecated device registry APIs from raising in tests. - async_get_device, the config entry parameters and merge_connections/merge_identifiers - parameters of async_update_device, and via_device on async_get_or_create are - deprecated and raise for core and core integration callers, disable them here so we - can run tests without triggering deprecation errors. + async_get_device, async_is_composite_device_id, the config entry parameters and + merge_connections/merge_identifiers parameters of async_update_device, and via_device + on async_get_or_create are deprecated and raise for core and core integration callers, + disable them here so we can run tests without triggering deprecation errors. Tests which use `mock_integration_frame` will not be affected by this fixture, so they can test the deprecation. @@ -3309,6 +3309,153 @@ async def test_async_is_composite_device_id( assert device_registry.async_is_composite_device_id("unknown_id") is None +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_async_is_composite_device_id_deprecated( + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test async_is_composite_device_id is deprecated. + + It logs for custom integrations and raises for core and core integrations. Use + async_get with include_composite_devices=False instead. + """ + what = "calls `device_registry.async_is_composite_device_id`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + device_registry.async_is_composite_device_id("some_device_id") + + assert caplog.text.count(what) == expected_log + + +async def test_async_get_include_composite_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get gates main, child and composite devices independently.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=entry_1.entry_id, + identifiers={("test", "child")}, + parent_device_id=device_1.id, + name="Child", + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # By default a composite id resolves to the synthesized composite + composite = device_registry.async_get(old_id) + assert composite is not None + assert composite.id == old_id + assert device_registry.async_get(old_id, include_child_devices=False) == composite + + # include_composite_devices=False resolves a composite id to None, matching + # `old_id in device_registry.devices`, which is composite-blind + assert old_id not in device_registry.devices + assert device_registry.async_get(old_id, include_composite_devices=False) is None + assert ( + device_registry.async_get( + old_id, include_child_devices=False, include_composite_devices=False + ) + is None + ) + + # A registered main device resolves regardless of include_composite_devices + assert ( + device_registry.async_get(device_1.id, include_composite_devices=False).id + == device_1.id + ) + assert ( + device_registry.async_get( + device_1.id, include_child_devices=False, include_composite_devices=False + ).id + == device_1.id + ) + + # An unknown id is None with or without the flag + assert ( + device_registry.async_get("unknown_id", include_composite_devices=False) is None + ) + + # include_main_devices=False, include_child_devices=False resolves only a composite + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_child_devices=False + ) + == composite + ) + # a registered main device, a child device and an unknown id resolve to None + assert ( + device_registry.async_get( + device_1.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + "unknown_id", include_main_devices=False, include_child_devices=False + ) + is None + ) + + # include_main_devices=False, include_composite_devices=False resolves only a child: + # a composite id resolves to None, a child device still resolves + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_composite_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, + include_main_devices=False, + include_composite_devices=False, + ) + == child_device + ) + + @pytest.mark.parametrize("load_registries", [False]) async def test_async_get_device_composite_reuses_pre_migration_id( hass: HomeAssistant, hass_storage: dict[str, Any]