Make device trackers create their own devices (#178003)

This commit is contained in:
Erik Montnemery
2026-08-03 14:09:40 +02:00
committed by GitHub
parent 54ea899f3f
commit 54a17f3ed8
4 changed files with 201 additions and 42 deletions
@@ -646,13 +646,13 @@ class ScannerEntity(
@override
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
# If mac_address is None, we can never find a matching device.
return (
# Do not disable if we won't activate our attach to device logic
# Do not disable if we won't activate our own device registration logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
# Disable if the tracked device is not known to any integration
or self._async_mac_address_registered()
)
@callback
@@ -681,12 +681,14 @@ class ScannerEntity(
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
def _async_mac_address_registered(self) -> bool:
"""Return if a device with the entity's MAC address is registered."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
return bool(
dr.async_get(self.hass).async_get_devices(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
)
@override
@@ -697,7 +699,7 @@ class ScannerEntity(
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
or not self._async_mac_address_registered()
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
@@ -707,27 +709,18 @@ class ScannerEntity(
await super().async_internal_added_to_hass()
return
dev_reg = dr.async_get(self.hass)
# find_device_entry may return a synthesized pre-migration composite whose id is
# not a real device and can't be assigned to an entity; resolve it to the split
# owned by this config entry so we attach to a concrete device.
if device_entry.id not in dev_reg.devices:
device_entry = next(
(
split
for split in dev_reg.async_get_devices_for_composite_device_id(
device_entry.id
)
if split.config_entry_id == self.platform.config_entry.entry_id
),
None,
)
# Register our own device now that the tracked device is known to another
# integration; matching the split of a pre-migration composite device prunes
# the identifiers and connections copied from the composite.
device_entry = dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.platform.config_entry.entry_id,
config_subentry_id=self.registry_entry.config_subentry_id,
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
default_name=self.hostname or self.mac_address,
)
# Attach entry to device
if (
device_entry is not None
and self.registry_entry.device_id != device_entry.id
):
# Link the entity's registry entry to the device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
+174 -8
View File
@@ -24,7 +24,12 @@ from homeassistant.components.device_tracker import (
TrackingType,
)
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigSubentryData,
)
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_FRIENDLY_NAME,
@@ -117,6 +122,7 @@ async def create_mock_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entities: list[Entity],
config_subentry_id: str | None = None,
) -> MockConfigEntry:
"""Create a device tracker platform with the specified entities."""
@@ -126,7 +132,7 @@ async def create_mock_platform(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test event platform via config entry."""
async_add_entities(entities)
async_add_entities(entities, config_subentry_id=config_subentry_id)
mock_platform(
hass,
@@ -1292,7 +1298,7 @@ async def test_scanner_entity_state(
ATTR_IP: ip_address,
ATTR_MAC: mac_address,
ATTR_HOST_NAME: hostname,
ATTR_FRIENDLY_NAME: "Device from other integration",
ATTR_FRIENDLY_NAME: hostname,
}
assert entity_state.state == STATE_NOT_HOME
@@ -1664,10 +1670,10 @@ async def test_scanner_entity_composite_device_without_own_split(
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""A composite with no split owned by the scanner's config entry attaches nothing.
"""A composite with no split owned by the scanner's config entry.
The composite id is not a real device and can't be assigned to an entity, so with no
split to resolve to the entity is added without a device instead of raising.
The tracked device is known to other integrations, so the scanner registers
its own device instead of attaching to another config entry's split.
"""
mac = TEST_MAC_ADDRESS
other_entry_1 = MockConfigEntry(domain="other_1")
@@ -1696,10 +1702,170 @@ async def test_scanner_entity_composite_device_without_own_split(
scanner_entity.entity_id = "device_tracker.composite_scanner"
await create_mock_platform(hass, config_entry, [scanner_entity])
# Added without a device rather than raising on the un-assignable composite id
# The scanner registers its own device instead of using a foreign split
entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
assert entity_entry is not None
assert entity_entry.device_id is None
assert entity_entry.device_id is not None
own_device = device_registry.async_get(entity_entry.device_id)
assert own_device is not None
assert own_device.config_entry_id == config_entry.entry_id
async def test_scanner_entity_attaches_to_own_device(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test the scanner reuses its config entry's device when several share the MAC."""
mac = TEST_MAC_ADDRESS
other_entry = MockConfigEntry(domain="other")
other_entry.add_to_hass(hass)
device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={("other", "x")},
)
own_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
)
scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
scanner_entity.entity_id = "device_tracker.shared_mac_scanner"
await create_mock_platform(hass, config_entry, [scanner_entity])
entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner")
assert entity_entry is not None
assert entity_entry.device_id == own_device.id
@pytest.mark.parametrize(
("hostname", "expected_device_name"),
[
pytest.param("tracked-host", "tracked-host", id="hostname"),
pytest.param(None, TEST_MAC_ADDRESS, id="no-hostname-falls-back-to-mac"),
],
)
async def test_scanner_entity_registers_own_device(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
hostname: str | None,
expected_device_name: str,
) -> None:
"""Test the scanner registers its own device when the MAC is known elsewhere."""
mac = TEST_MAC_ADDRESS
foreign_ids = set()
for domain in ("other_1", "other_2"):
entry = MockConfigEntry(domain=domain)
entry.add_to_hass(hass)
foreign_ids.add(
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(domain, "x")},
).id
)
scanner_entity = MockScannerEntity(
mac_address=mac, hostname=hostname, unique_id=f"{mac}_scanner"
)
scanner_entity.entity_id = "device_tracker.shared_mac_scanner"
await create_mock_platform(hass, config_entry, [scanner_entity])
entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner")
assert entity_entry is not None
assert entity_entry.device_id is not None
assert entity_entry.device_id not in foreign_ids
own_device = device_registry.async_get(entity_entry.device_id)
assert own_device is not None
assert own_device.config_entry_id == config_entry.entry_id
assert own_device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))}
assert own_device.name == expected_device_name
async def test_scanner_entity_own_device_in_subentry(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test the scanner's own device is created in the entity's config subentry."""
mac = TEST_MAC_ADDRESS
subentry_id = "mock-subentry-id"
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
subentries_data=(
ConfigSubentryData(
data={},
subentry_id=subentry_id,
subentry_type="test",
title="Mock subentry",
unique_id=None,
),
),
)
config_entry.add_to_hass(hass)
other_entry = MockConfigEntry(domain="other")
other_entry.add_to_hass(hass)
device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={("other", "x")},
)
scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
scanner_entity.entity_id = "device_tracker.subentry_scanner"
await create_mock_platform(
hass, config_entry, [scanner_entity], config_subentry_id=subentry_id
)
entity_entry = entity_registry.async_get("device_tracker.subentry_scanner")
assert entity_entry is not None
assert entity_entry.config_subentry_id == subentry_id
assert entity_entry.device_id is not None
own_device = device_registry.async_get(entity_entry.device_id)
assert own_device is not None
assert own_device.config_entry_id == config_entry.entry_id
assert own_device.config_subentry_id == subentry_id
async def test_scanner_entity_prunes_composite_identifiers(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test re-registration prunes composite relics from the scanner's split device.
Splits of a pre-migration composite keep the identifiers and connections copied
from the composite until the owning config entry re-registers the device; the
scanner's registration provides only the MAC connection, dropping the rest.
"""
mac = TEST_MAC_ADDRESS
own_split = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={("other", "copied-identifier")},
)
device_registry.devices[own_split.id] = attr.evolve(
own_split,
composite_device_id="composite00000000000000000000000",
has_composite_identifiers=True,
)
scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner")
scanner_entity.entity_id = "device_tracker.composite_scanner"
await create_mock_platform(hass, config_entry, [scanner_entity])
entity_entry = entity_registry.async_get("device_tracker.composite_scanner")
assert entity_entry is not None
assert entity_entry.device_id == own_split.id
device = device_registry.async_get(own_split.id)
assert device is not None
assert device.identifiers == set()
assert device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))}
async def test_connected_device_registered(
@@ -193,7 +193,7 @@ async def test_device_trackers_numerical_name(hass: HomeAssistant) -> None:
device_3 = hass.states.get("device_tracker.123")
assert device_3
assert device_3.state == "home"
assert device_3.attributes["friendly_name"] == "Device 2 123"
assert device_3.attributes["friendly_name"] == "123 123"
assert device_3.attributes["ip"] == "0.0.0.3"
assert device_3.attributes["mac"] == "00:00:00:00:00:03"
assert device_3.attributes["host_name"] == "123"
@@ -214,7 +214,7 @@ async def test_hub_wifiwave2(hass: HomeAssistant) -> None:
device_4 = hass.states.get("device_tracker.device_4")
assert device_4
assert device_4.state == "home"
assert device_4.attributes["friendly_name"] == "Device 3 Device_4"
assert device_4.attributes["friendly_name"] == "Device_4 Device_4"
assert device_4.attributes["ip"] == "0.0.0.4"
assert device_4.attributes["mac"] == "00:00:00:00:00:04"
assert device_4.attributes["host_name"] == "Device_4"
@@ -41,7 +41,7 @@
# name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.switch_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Device 6 Switch 1',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '00:00:00:00:01:01 Switch 1',
<DeviceTrackerEntityStateAttribute.IN_ZONES: 'in_zones'>: list([
'zone.home',
]),
@@ -100,7 +100,7 @@
# name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.wd_client_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Device 1 wd_client_1',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'wd_client_1 wd_client_1',
<ScannerEntityStateAttribute.HOST_NAME: 'host_name'>: 'wd_client_1',
<DeviceTrackerEntityStateAttribute.IN_ZONES: 'in_zones'>: list([
]),
@@ -158,7 +158,7 @@
# name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.ws_client_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Device 0 ws_client_1',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'ws_client_1 ws_client_1',
<ScannerEntityStateAttribute.HOST_NAME: 'host_name'>: 'ws_client_1',
<DeviceTrackerEntityStateAttribute.IN_ZONES: 'in_zones'>: list([
]),