mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Make device trackers create their own devices (#178003)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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([
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user