Add parameter include_composite_devices to DeviceRegistry.async_get (#179594)

This commit is contained in:
Erik Montnemery
2026-08-20 13:06:53 +02:00
committed by GitHub
parent 53276b2786
commit f1e44ab125
13 changed files with 262 additions and 68 deletions
@@ -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 (
@@ -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(
+13 -11
View File
@@ -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,
@@ -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
+5 -5
View File
@@ -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
+7 -3
View File
@@ -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:
+37 -15
View File
@@ -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(
+10 -8
View File
@@ -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(
+3 -3
View File
@@ -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 = (
(
+2 -2
View File
@@ -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)
+10 -9
View File
@@ -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(
@@ -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
+151 -4
View File
@@ -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]