Add child devices (#178666)

Co-authored-by: Artur Pragacz <artur@pragacz.com>
This commit is contained in:
Erik Montnemery
2026-08-13 13:53:15 +02:00
committed by GitHub
co-authored by Artur Pragacz
parent 24a15d3ce4
commit ad96b06be6
108 changed files with 6542 additions and 371 deletions
+11 -3
View File
@@ -5,6 +5,8 @@ from collections.abc import Callable
import voluptuous as vol
from homeassistant.helpers import device_registry as dr
from .const import POLICY_CONTROL, POLICY_EDIT, POLICY_READ, SUBCAT_ALL
from .models import PermissionLookup
from .types import CategoryType, SubCategoryDict, ValueType
@@ -58,12 +60,18 @@ def _lookup_area(
if entity_entry is None or entity_entry.device_id is None:
return None
device_entry = perm_lookup.device_registry.async_get(entity_entry.device_id)
device_registry = perm_lookup.device_registry
device_entry = device_registry.async_get(entity_entry.device_id)
if device_entry is None or device_entry.area_id is None:
if device_entry is None:
return None
return area_dict.get(device_entry.area_id)
area_id = dr.async_get_effective_area_id(device_registry.hass, device_entry)
if area_id is None:
return None
return area_dict.get(area_id)
def _lookup_device(
@@ -44,7 +44,11 @@ def async_get_entry_id_for_service_call(
"""Get the entry ID related to a service call (by device ID)."""
device_registry = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
if (device_entry := device_registry.async_get(device_id)) is None:
if (
device_entry := device_registry.async_get(
device_id, include_child_devices=False
)
) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
+38 -16
View File
@@ -734,6 +734,35 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
def _device_payload(device_entry: dr.AnyDeviceEntry) -> dict[str, Any]:
"""Return the analytics payload for a device or child device."""
if isinstance(device_entry, dr.ChildDeviceEntry):
# A child device carries no hardware or firmware metadata of its own;
# it is reported with its parent referenced as via_device.
return {
"entry_type": None,
"has_configuration_url": False,
"hw_version": None,
"manufacturer": None,
"model": None,
"model_id": None,
"sw_version": None,
"via_device": device_entry.parent_device_id,
"entities": [],
}
return {
"entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version,
"manufacturer": device_entry.manufacturer,
"model": device_entry.model,
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
"entities": [],
}
async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices for a snapshot."""
dev_reg = dr.async_get(hass)
@@ -745,13 +774,17 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
removed_devices: set[str] = set()
# Get device list
for device_entry in dev_reg.devices.values():
for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()):
config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id)
if config_entry is None:
continue
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
# Only full devices can be service devices; child devices never are.
if (
isinstance(device_entry, dr.DeviceEntry)
and device_entry.entry_type is dr.DeviceEntryType.SERVICE
):
removed_devices.add(device_entry.id)
continue
@@ -849,23 +882,12 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict:
removed_devices.add(device_id)
continue
device_entry = dev_reg.devices[device_id]
resolved_device = dev_reg.async_get(device_id)
assert resolved_device is not None
device_id_mapping[device_id] = (integration_domain, len(devices_info))
devices_info.append(
{
"entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version,
"manufacturer": device_entry.manufacturer,
"model": device_entry.model,
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
"entities": [],
}
)
devices_info.append(_device_payload(resolved_device))
# Fill out via_device with new device ids
for integration_info in integrations_info.values():
@@ -1382,7 +1382,7 @@ class PipelineRun:
if device_entry is None:
return False
area_id = device_entry.area_id
area_id = dr.async_get_effective_area_id(self.hass, device_entry)
if area_id is None:
return False
@@ -1402,7 +1402,9 @@ class PipelineRun:
if target_device_entry is None:
return False
target_area_id = target_device_entry.area_id
target_area_id = dr.async_get_effective_area_id(
self.hass, target_device_entry
)
if target_area_id != area_id:
return False
+1
View File
@@ -193,6 +193,7 @@ class AveaLight(LightEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.HS}
_attr_device_info: DeviceInfo | None = None
def __init__(self, light: avea.Bulb, address: str) -> None:
"""Initialize an AveaLight."""
@@ -345,9 +345,14 @@ async def async_update_device(
hw_version=details.get(ADAPTER_HW_VERSION),
)
if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)):
# The bluetooth scanner may be child device; link to its parent.
if isinstance(via_device_entry, dr.ChildDeviceEntry):
via_device_id = via_device_entry.parent_device_id
kwargs: dict[str, Any] = {"via_device_id": via_device_id}
if not device_entry.area_id and via_device_entry.area_id:
kwargs["area_id"] = via_device_entry.area_id
# The source device may be an area-inheriting child, so use its effective area.
via_area_id = dr.async_get_effective_area_id(hass, via_device_entry)
if not device_entry.area_id and via_area_id:
kwargs["area_id"] = via_area_id
device_registry.async_update_device(device_entry.id, **kwargs)
+1 -1
View File
@@ -129,7 +129,7 @@ def _resolve_config_entry(
device_id: str = service_call.data[ATTR_DEVICE_ID]
device_registry = dr.async_get(service_call.hass)
device_entry = device_registry.async_get(device_id)
device_entry = device_registry.async_get(device_id, include_child_devices=False)
if device_entry is None:
raise ServiceValidationError(
@@ -500,12 +500,25 @@ class CloudGoogleConfig(AbstractConfig):
if event.data["action"] != "update" or "area_id" not in event.data["changes"]:
return
device_id = event.data["device_id"]
ent_reg = er.async_get(self.hass)
# Children without an area of their own inherit the parent's area, so a
# parent area change also changes the effective area of their entities.
device_ids = [device_id]
device_ids.extend(
child.id
for child in dr.async_entries_for_parent_device(
dr.async_get(self.hass), device_id
)
if child.area_id is None
)
# Check if any exposed entity uses the device area
if not any(
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
for entity_entry in er.async_entries_for_device(
er.async_get(self.hass), event.data["device_id"]
)
for check_device_id in device_ids
for entity_entry in er.async_entries_for_device(ent_reg, check_device_id)
):
return
@@ -1,7 +1,7 @@
"""HTTP views to interact with the device registry."""
import logging
from typing import Any, cast
from typing import Any
import voluptuous as vol
@@ -11,7 +11,7 @@ from homeassistant.components.websocket_api import require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler
from homeassistant.helpers.device_registry import DeviceEntryDisabler
_LOGGER = logging.getLogger(__name__)
@@ -92,7 +92,8 @@ def websocket_list_devices(
inner = b",".join(
[
entry.json_repr
for entry in registry.devices.values()
for container in (registry.devices, registry.child_devices)
for entry in container.values()
if entry.json_repr is not None
]
)
@@ -127,10 +128,18 @@ def websocket_list_linked_devices(
)
return
# A child device is never linked: its identifiers share the parent's
# per-config-entry namespace, so matching them against other entries' main
# devices is not meaningful.
if isinstance(device, dr.ChildDeviceEntry):
connection.send_result(msg["id"], {"linked_devices": []})
return
linked_devices = [
entry.id
for entry in registry.async_get_devices(
identifiers=device.identifiers, connections=device.connections
identifiers=device.identifiers,
connections=device.connections,
)
if entry.id != device_id
]
@@ -170,7 +179,12 @@ def websocket_update_device(
# Convert labels to a set
msg["labels"] = set(msg["labels"])
entry = cast(DeviceEntry, registry.async_update_device(**msg))
entry: dr.AnyDeviceEntry | None
if msg["device_id"] in registry.child_devices:
entry = registry.async_update_child_device(**msg)
else:
entry = registry.async_update_device(**msg)
assert entry is not None
connection.send_message(websocket_api.result_message(msg_id, entry.dict_repr))
@@ -1254,12 +1254,10 @@ class DefaultAgent(ConversationEntity):
area_id = entity_entry.area_id
device_id = entity_entry.device_id
if (
area_id is None
and device_id is not None
and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
):
area_id = device_entry.area_id
if area_id is None and device_id is not None:
device_registry = dr.async_get(hass)
if (device_entry := device_registry.async_get(device_id)) is not None:
area_id = dr.async_get_effective_area_id(hass, device_entry)
if area_id is None:
return None, device_id
@@ -702,7 +702,9 @@ async def async_validate_trigger_config(
config = TRIGGER_SCHEMA(config)
device_registry = dr.async_get(hass)
device = device_registry.async_get(config[CONF_DEVICE_ID])
device = device_registry.async_get(
config[CONF_DEVICE_ID], include_child_devices=False
)
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
@@ -731,7 +733,14 @@ async def async_attach_trigger(
event_data: dict[str, int | str] = {}
device_registry = dr.async_get(hass)
device = device_registry.devices[config[CONF_DEVICE_ID]]
device = device_registry.async_get(
config[CONF_DEVICE_ID], include_child_devices=False
)
if not device:
raise InvalidDeviceAutomationConfig(
f"deCONZ trigger device with ID {config[CONF_DEVICE_ID]} not found"
)
deconz_event = _get_deconz_event_from_device(hass, device)
if event_id := deconz_event.serial:
@@ -764,9 +773,9 @@ async def async_get_triggers(
Generate device trigger list.
"""
device_registry = dr.async_get(hass)
device = device_registry.devices[device_id]
device = device_registry.async_get(device_id, include_child_devices=False)
if device.model not in REMOTES:
if device is None or device.model not in REMOTES:
return []
triggers = []
@@ -38,7 +38,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -199,7 +199,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
unit_time: UnitOfTime,
max_sub_interval: timedelta | None,
unique_id: str | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the derivative sensor."""
self._attr_unique_id = unique_id
@@ -114,10 +114,12 @@ def _async_register_mac(
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
device_entry = dev_reg.async_get(
ev.data["device_id"], include_child_devices=False
)
if device_entry is None:
# This should not happen, since the device was just created.
# A child device resolves to None here; it has no MAC to match.
return
# Check if device has a mac
@@ -314,7 +314,10 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
if info.device_diagnostics is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
data = await info.device_diagnostics(hass, config_entry, device)
# A device's diagnostics may be requested for a child device, but the
# callback is currently typed for a main device. Ignoring the mismatch until
# DiagnosticsPlatformData.device_diagnostics is widened to accept AnyDeviceEntry.
data = await info.device_diagnostics(hass, config_entry, device) # type: ignore[arg-type]
return await _async_get_json_file_response(
hass, data, data_issues, filename, config_entry.domain, d_id, sub_id
)
@@ -107,7 +107,7 @@ async def async_setup_entry(
)
and (existing_entry := ent_reg.async_get(existing_entity_id))
and (device_id := existing_entry.device_id)
and (device_entry := dev_reg.async_get(device_id))
and (device_entry := dev_reg.async_get(device_id, include_child_devices=False))
and (dr.CONNECTION_UPNP, udn) not in device_entry.connections
):
# If the existing device is missing the udn connection, add it
@@ -41,7 +41,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -119,7 +119,7 @@ async def _async_setup_config(
config: Mapping[str, Any],
unique_id: str | None,
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
name: str = config[CONF_NAME]
switch_entity_id: str = config[CONF_HUMIDIFIER]
@@ -190,7 +190,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
away_fixed: bool | None,
sensor_stale_duration: timedelta | None,
unique_id: str | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the hygrostat."""
self._name = name
@@ -51,7 +51,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity import CONTEXT_RECENT_TIME_SECONDS
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -167,7 +167,7 @@ async def _async_setup_config(
config: Mapping[str, Any],
unique_id: str | None,
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Set up the generic thermostat platform."""
@@ -247,7 +247,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
target_temperature_step: float | None,
unit: UnitOfTemperature,
unique_id: str | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the thermostat."""
self._attr_name = name
@@ -59,7 +59,7 @@ def _get_registry_entries(
hass: HomeAssistant, entity_id: str
) -> tuple[
er.RegistryEntry | None,
dr.DeviceEntry | None,
dr.AnyDeviceEntry | None,
ar.AreaEntry | None,
]:
"""Get registry entries."""
@@ -68,16 +68,13 @@ def _get_registry_entries(
area_reg = ar.async_get(hass)
if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id:
device_entry = dev_reg.devices.get(entity_entry.device_id)
device_entry = dev_reg.async_get(entity_entry.device_id)
else:
device_entry = None
if entity_entry and entity_entry.area_id:
area_id = entity_entry.area_id
elif device_entry and device_entry.area_id:
area_id = device_entry.area_id
else:
area_id = None
area_id = (
er.async_get_effective_area_id(hass, entity_entry) if entity_entry else None
)
if area_id is not None:
area_entry = area_reg.async_get_area(area_id)
@@ -668,18 +665,19 @@ class GoogleEntity:
device["matterOriginalVendorId"] = matter_info["vendor_id"]
device["matterOriginalProductId"] = matter_info["product_id"]
# Add deviceInfo
device_info = {}
# Add deviceInfo (child devices carry no hardware/firmware fields)
if isinstance(device_entry, dr.DeviceEntry):
device_info = {}
if device_entry.manufacturer:
device_info["manufacturer"] = device_entry.manufacturer
if device_entry.model:
device_info["model"] = device_entry.model
if device_entry.sw_version:
device_info["swVersion"] = device_entry.sw_version
if device_entry.manufacturer:
device_info["manufacturer"] = device_entry.manufacturer
if device_entry.model:
device_info["model"] = device_entry.model
if device_entry.sw_version:
device_info["swVersion"] = device_entry.sw_version
if device_info:
device["deviceInfo"] = device_info
if device_info:
device["deviceInfo"] = device_info
return device
+5 -1
View File
@@ -455,7 +455,11 @@ def async_register_network_storage_services(
"""Handle service calls for Hass.io."""
coordinator: HassioMainDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
if (
device := dev_reg.async_get(
service.data[ATTR_DEVICE_ID], include_child_devices=False
)
) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
@@ -27,7 +27,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -223,7 +223,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
name: str,
unique_id: str | None,
state_class: SensorStateClass | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the HistoryStats sensor."""
super().__init__(coordinator, name)
@@ -119,14 +119,12 @@ def async_get_exposed_entities(
area_names.append(area_entry.name)
area_names.extend(sorted(area_entry.aliases))
elif device_entry is not None:
# Check device area
# Check the device's effective area
if (
device_entry.area_id is not None
and (
area_entry := area_registry.async_get_area(device_entry.area_id)
)
is not None
):
device_area_id := dr.async_get_effective_area_id(hass, device_entry)
) is not None and (
area_entry := area_registry.async_get_area(device_area_id)
) is not None:
area_names.append(area_entry.name)
area_names.extend(sorted(area_entry.aliases))
+21 -5
View File
@@ -494,6 +494,9 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None:
for device_id in referenced.referenced_devices:
if not (dev_reg_ent := dev_reg.async_get(device_id)):
raise HomeAssistantError(f"No device found for device id: {device_id}")
if isinstance(dev_reg_ent, dr.ChildDeviceEntry):
# A child device carries no HomeKit pairing; only its parent can.
continue
macs = [
cval
for ctype, cval in dev_reg_ent.connections
@@ -1068,7 +1071,18 @@ class HomeKit:
dev_reg = dr.async_get(self.hass)
valid_device_ids = []
for device_id in self._devices:
if not dev_reg.async_get(device_id):
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):
_LOGGER.warning(
(
"HomeKit %s cannot add device %s because a child device cannot"
" be a HomeKit accessory"
),
self._name,
device_id,
)
else:
_LOGGER.warning(
(
"HomeKit %s cannot add device %s because it is missing from the"
@@ -1077,8 +1091,6 @@ class HomeKit:
self._name,
device_id,
)
else:
valid_device_ids.append(device_id)
for device_id, device_triggers in (
await device_automation.async_get_device_automations(
self.hass,
@@ -1086,7 +1098,7 @@ class HomeKit:
valid_device_ids,
)
).items():
device = dev_reg.async_get(device_id)
device = dev_reg.async_get(device_id, include_child_devices=False)
assert device is not None
valid_device_triggers: list[dict[str, Any]] = []
for trigger in device_triggers:
@@ -1216,7 +1228,11 @@ class HomeKit:
"""Set attributes that will be used for homekit device info."""
ent_cfg = self._config[entity_id]
if ent_reg_ent.device_id:
if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id):
dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id)
if isinstance(dev_reg_ent, dr.ChildDeviceEntry):
# A child device has no hardware info of its own; use the parent's
dev_reg_ent = dev_reg.devices.get(dev_reg_ent.parent_device_id)
if dev_reg_ent is not None:
self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg)
if ATTR_MANUFACTURER not in ent_cfg:
try:
@@ -122,7 +122,11 @@ def _async_get_diagnostics(
devices = data["devices"] = []
for device_id in connection.devices.values():
if not (device := device_registry.async_get(device_id)):
if not (
device := device_registry.async_get(
device_id, include_child_devices=False
)
):
continue
devices.append(_async_get_diagnostics_for_device(hass, device))
@@ -42,7 +42,9 @@ async def async_validate_trigger_config(
device_id = config[CONF_DEVICE_ID]
# lookup device in HASS DeviceRegistry
dev_reg: dr.DeviceRegistry = dr.async_get(hass)
if (device_entry := dev_reg.async_get(device_id)) is None:
if (
device_entry := dev_reg.async_get(device_id, include_child_devices=False)
) is None:
raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid")
for entry in entries:
@@ -65,7 +67,9 @@ async def async_attach_trigger(
device_id = config[CONF_DEVICE_ID]
# lookup device in HASS DeviceRegistry
dev_reg: dr.DeviceRegistry = dr.async_get(hass)
if (device_entry := dev_reg.async_get(device_id)) is None:
if (
device_entry := dev_reg.async_get(device_id, include_child_devices=False)
) is None:
raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid")
entry: HueConfigEntry | None = next(
@@ -101,7 +105,9 @@ async def async_get_triggers(
return []
# lookup device in HASS DeviceRegistry
dev_reg: dr.DeviceRegistry = dr.async_get(hass)
if (device_entry := dev_reg.async_get(device_id)) is None:
if (
device_entry := dev_reg.async_get(device_id, include_child_devices=False)
) is None:
raise ValueError(f"Device ID {device_id} is not valid")
# Iterate all config entries for this device
@@ -38,7 +38,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -319,7 +319,7 @@ class IntegrationSensor(RestoreSensor):
unit_prefix: str | None,
unit_time: UnitOfTime,
max_sub_interval: timedelta | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the integration sensor."""
self._attr_unique_id = unique_id
@@ -20,6 +20,7 @@ class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
"""Define a generic class for IntelliClima entities."""
_attr_has_entity_name = True
_attr_device_info: DeviceInfo
def __init__(
self,
@@ -52,8 +53,6 @@ class IntelliClimaECOEntity(IntelliClimaEntity):
"""Class initializer."""
super().__init__(coordinator, device)
self._attr_device_info: DeviceInfo = self.device_info or DeviceInfo()
self._attr_device_info[ATTR_MODEL] = "ECOCOMFORT 2.0"
self._attr_device_info[ATTR_SW_VERSION] = device.fw
self._attr_device_info[ATTR_CONNECTIONS] = {
+3 -1
View File
@@ -91,7 +91,9 @@ def async_get_tools(
device := dr.async_get(hass).async_get(llm_context.device_id)
):
area_reg = ar.async_get(hass)
if device.area_id and (area := area_reg.async_get_area(device.area_id)):
if (device_area_id := dr.async_get_effective_area_id(hass, device)) and (
area := area_reg.async_get_area(device_area_id)
):
if area.floor_id:
floor = fr.async_get(hass).async_get_floor(area.floor_id)
+10 -8
View File
@@ -294,11 +294,10 @@ class TimerManager:
# Fill in area/floor info
device_registry = dr.async_get(self.hass)
if device_id and (device := device_registry.async_get(device_id)):
timer.area_id = device.area_id
area_id = dr.async_get_effective_area_id(self.hass, device)
timer.area_id = area_id
area_registry = ar.async_get(self.hass)
if device.area_id and (
area := area_registry.async_get_area(device.area_id)
):
if area_id and (area := area_registry.async_get_area(area_id)):
timer.area_name = _normalize_name(area.name)
timer.floor_id = area.floor_id
@@ -622,8 +621,8 @@ def _find_timer(
area_registry = ar.async_get(hass)
if (
(device := device_registry.async_get(device_id))
and device.area_id
and (area := area_registry.async_get_area(device.area_id))
and (area_id := dr.async_get_effective_area_id(hass, device))
and (area := area_registry.async_get_area(area_id))
):
# Try area
matching_area_timers = [
@@ -729,11 +728,14 @@ def _find_timers(
# Use device id to order remaining timers
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if (device is None) or (device.area_id is None):
if device is None:
return matching_timers
area_id = dr.async_get_effective_area_id(hass, device)
if area_id is None:
return matching_timers
area_registry = ar.async_get(hass)
area = area_registry.async_get_area(device.area_id)
area = area_registry.async_get_area(area_id)
if area is None:
return matching_timers
+17 -11
View File
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEGREE, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import ChildDeviceInfo, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
@@ -32,7 +32,7 @@ async def async_setup_entry(
"2_ch_power_strip",
)
via_device_id = dr.async_get_device_id_by_identifier(
parent_device_id = dr.async_get_device_id_by_identifier(
hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id
)
@@ -47,7 +47,7 @@ async def async_setup_entry(
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
unit_of_measurement=UnitOfPower.WATT,
via_device_id=via_device_id,
parent_device_id=parent_device_id,
),
DemoSensor(
device_unique_id="outlet_2",
@@ -58,7 +58,7 @@ async def async_setup_entry(
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
unit_of_measurement=UnitOfPower.WATT,
via_device_id=via_device_id,
parent_device_id=parent_device_id,
),
DemoSensor(
device_unique_id="statistics_issues",
@@ -128,6 +128,7 @@ class DemoSensor(SensorEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_device_info: DeviceInfo | ChildDeviceInfo
def __init__(
self,
@@ -140,7 +141,7 @@ class DemoSensor(SensorEntity):
device_class: SensorDeviceClass | None,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
via_device_id: str | None = None,
parent_device_id: str | None = None,
) -> None:
"""Initialize the sensor."""
self._attr_device_class = device_class
@@ -151,9 +152,14 @@ class DemoSensor(SensorEntity):
self._attr_state_class = state_class
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_unique_id)},
name=device_name,
)
if via_device_id:
self._attr_device_info["via_device_id"] = via_device_id
if parent_device_id is not None:
self._attr_device_info = ChildDeviceInfo(
identifiers={(DOMAIN, device_unique_id)},
name=device_name,
parent_device_id=parent_device_id,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_unique_id)},
name=device_name,
)
+17 -11
View File
@@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import ChildDeviceInfo, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -28,7 +28,7 @@ async def async_setup_entry(
"2_ch_power_strip",
)
via_device_id = dr.async_get_device_id_by_identifier(
parent_device_id = dr.async_get_device_id_by_identifier(
hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id
)
@@ -40,7 +40,7 @@ async def async_setup_entry(
entity_name=None,
state=False,
assumed=False,
via_device_id=via_device_id,
parent_device_id=parent_device_id,
),
DemoSwitch(
unique_id="outlet_2",
@@ -48,7 +48,7 @@ async def async_setup_entry(
entity_name=None,
state=True,
assumed=False,
via_device_id=via_device_id,
parent_device_id=parent_device_id,
),
]
)
@@ -59,6 +59,7 @@ class DemoSwitch(SwitchEntity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_device_info: DeviceInfo | ChildDeviceInfo
def __init__(
self,
@@ -70,7 +71,7 @@ class DemoSwitch(SwitchEntity):
assumed: bool,
translation_key: str | None = None,
device_class: SwitchDeviceClass | None = None,
via_device_id: str | None = None,
parent_device_id: str | None = None,
) -> None:
"""Initialize the Demo switch."""
self._attr_assumed_state = assumed
@@ -78,12 +79,17 @@ class DemoSwitch(SwitchEntity):
self._attr_translation_key = translation_key
self._attr_is_on = state
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
if via_device_id:
self._attr_device_info["via_device_id"] = via_device_id
if parent_device_id is not None:
self._attr_device_info = ChildDeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
parent_device_id=parent_device_id,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
@override
@@ -54,7 +54,9 @@ async def async_get_triggers(
) -> list[dict[str, str]]:
"""List device triggers for LCN devices."""
device_registry = dr.async_get(hass)
if (device := device_registry.async_get(device_id)) is None:
if (
device := device_registry.async_get(device_id, include_child_devices=False)
) is None:
return []
identifier = next(iter(device.identifiers))
@@ -53,7 +53,7 @@ def async_get_device_entry_by_device_id(
Raises ValueError if device ID is invalid.
"""
device_reg = dr.async_get(hass)
if (device := device_reg.async_get(device_id)) is None:
if (device := device_reg.async_get(device_id, include_child_devices=False)) is None:
raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.")
return device
+1 -1
View File
@@ -80,7 +80,7 @@ def get_device_id(
def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode | None:
"""Get node id from ha device id."""
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(ha_device_id)
device = dev_reg.async_get(ha_device_id, include_child_devices=False)
if device is None:
raise MissingNode(f"Invalid device ID: {ha_device_id}")
return get_node_from_device_entry(hass, device)
@@ -34,7 +34,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -149,7 +149,7 @@ class MoldIndicator(SensorEntity):
indoor_humidity_sensor: str,
calib_factor: float,
unique_id: str | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the sensor."""
self._attr_name = name
@@ -394,7 +394,9 @@ async def handle_webhook(
device_registry = dr.async_get(hass)
device_id = data[ATTR_DEVICE_ID]
if not (device := device_registry.async_get(device_id)):
if not (
device := device_registry.async_get(device_id, include_child_devices=False)
):
return Response(
text=f"Device not found: {device_id}",
status=HTTPStatus.BAD_REQUEST,
@@ -131,7 +131,9 @@ class MotionEyeMediaSource(MediaSource):
def _get_device_or_raise(self, device_id: str) -> dr.DeviceEntry:
"""Get a config entry from a URL."""
device_registry = dr.async_get(self.hass)
if not (device := device_registry.async_get(device_id)):
if not (
device := device_registry.async_get(device_id, include_child_devices=False)
):
raise MediaSourceError(f"Unable to find device with id: {device_id}")
return device
@@ -38,7 +38,7 @@ async def async_get_triggers(
) -> list[dict[str, str]]:
"""List device triggers for Nanoleaf devices."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
device_entry = device_registry.async_get(device_id, include_child_devices=False)
if device_entry is None:
raise DeviceNotFound(f"Device ID {device_id} is not valid")
if device_entry.model not in TOUCH_MODELS:
@@ -69,7 +69,9 @@ async def async_validate_trigger_config(
config = TRIGGER_SCHEMA(config)
device_registry = dr.async_get(hass)
device = device_registry.async_get(config[CONF_DEVICE_ID])
device = device_registry.async_get(
config[CONF_DEVICE_ID], include_child_devices=False
)
if not device or device.model is None:
raise InvalidDeviceAutomationConfig(
@@ -98,7 +100,7 @@ async def async_get_triggers(
for entry in er.async_entries_for_device(registry, device_id):
if (
device := device_registry.async_get(device_id)
device := device_registry.async_get(device_id, include_child_devices=False)
) is None or device.model is None:
continue
@@ -137,7 +139,9 @@ async def async_attach_trigger(
) -> CALLBACK_TYPE:
"""Attach a trigger."""
device_registry = dr.async_get(hass)
device = device_registry.async_get(config[CONF_DEVICE_ID])
device = device_registry.async_get(
config[CONF_DEVICE_ID], include_child_devices=False
)
if not device:
return lambda: None
+1
View File
@@ -28,6 +28,7 @@ class NexiaEntity(CoordinatorEntity[NexiaDataUpdateCoordinator]):
"""Base class for nexia entities."""
_attr_attribution = ATTRIBUTION
_attr_device_info: DeviceInfo | None = None
def __init__(self, coordinator: NexiaDataUpdateCoordinator, unique_id: str) -> None:
"""Initialize the entity."""
+1
View File
@@ -55,6 +55,7 @@ class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]):
"""Base entity for common entities."""
_attr_has_entity_name = True
_attr_device_info: DeviceInfo | None = None
def __init__(
self,
@@ -70,7 +70,9 @@ def _get_runtime_data_from_device_id(
) -> NutRuntimeData | None:
"""Find the runtime data for device ID and return None on error."""
device_registry = dr.async_get(hass)
if (device := device_registry.async_get(device_id)) is None:
if (
device := device_registry.async_get(device_id, include_child_devices=False)
) is None:
return None
return _get_runtime_data_for_device(hass, device)
@@ -98,7 +100,9 @@ def _get_runtime_data_from_device_id_exception_on_failure(
) -> NutRuntimeData | None:
"""Find the runtime data for device ID and raise exception on error."""
device_registry = dr.async_get(hass)
if (device := device_registry.async_get(device_id)) is None:
if (
device := device_registry.async_get(device_id, include_child_devices=False)
) is None:
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_not_found",
@@ -87,7 +87,9 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry:
device_id: str = call.data[ATTR_DEVICE_ID]
device_registry = dr.async_get(call.hass)
if (device := device_registry.async_get(device_id)) is None:
if (
device := device_registry.async_get(device_id, include_child_devices=False)
) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
@@ -20,6 +20,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
_attr_has_entity_name = True
_attr_name: str | None = None
_attr_device_info: DeviceInfo | None = None
def __init__(
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
@@ -54,7 +54,7 @@ SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
def _async_get_device(call: ServiceCall, device_id: str) -> dr.DeviceEntry:
"""Get a device entry from a device ID."""
device_reg = dr.async_get(call.hass)
if (device := device_reg.async_get(device_id)) is None:
if (device := device_reg.async_get(device_id, include_child_devices=False)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
@@ -369,11 +369,23 @@ class PrometheusMetrics:
device_id = event.data["device_id"]
_LOGGER.debug("Handling device update for %s", device_id)
self._refresh_device_entities_area(device_id)
# Child devices without an area of their own inherit the parent's area,
# so a parent area change must refresh their entities too.
for child in dr.async_entries_for_parent_device(
self.device_registry, device_id
):
if child.area_id is None:
self._refresh_device_entities_area(child.id)
def _refresh_device_entities_area(self, device_id: str) -> None:
"""Recompute the area label of a device's area-inheriting entities."""
device = self.device_registry.async_get(device_id)
if device is None:
return
area_id = device.area_id
area_id = dr.async_get_effective_area_id(self.device_registry.hass, device)
for entity_id in (
entity.entity_id
@@ -612,7 +624,9 @@ class PrometheusMetrics:
if area_id is None and entity.device_id is not None:
device = self.device_registry.async_get(entity.device_id)
if device is not None:
area_id = device.area_id
area_id = dr.async_get_effective_area_id(
self.device_registry.hass, device
)
return area_id
+1 -1
View File
@@ -32,7 +32,7 @@ async def _async_play_chime(service_call: ServiceCall) -> None:
for device_id in service_data[ATTR_DEVICE_ID]:
config_entry = None
device = device_registry.async_get(device_id)
device = device_registry.async_get(device_id, include_child_devices=False)
if device is not None:
for entry_id in device.config_entries:
config_entry = service_call.hass.config_entries.async_get_entry(
@@ -19,7 +19,7 @@ def async_get_device_entry_by_device_id(
Raises ValueError if device ID is invalid.
"""
device_reg = dr.async_get(hass)
if (device := device_reg.async_get(device_id)) is None:
if (device := device_reg.async_get(device_id, include_child_devices=False)) is None:
raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.")
return device
+37 -9
View File
@@ -283,6 +283,13 @@ class Searcher:
self._add(ItemType.DEVICE, device_entry.id)
self._async_search_device(device_entry.id, entry_point=False)
# async_entries_for_config_entry returns mains only; add this entry's children.
for child_device_entry in dr.async_child_entries_for_config_entry(
self._device_registry, config_entry_id
):
self._add(ItemType.DEVICE, child_device_entry.id)
self._async_search_device(child_device_entry.id, entry_point=False)
for entity_entry in er.async_entries_for_config_entry(
self._entity_registry, config_entry_id
):
@@ -323,9 +330,17 @@ class Searcher:
# Add all entity information as well
self._async_search_entity(entity_entry.entity_id, entry_point=False)
# Child devices are structurally part of this device; surface them and their
# entities, the way an area or config entry surfaces the devices under it.
for child_device_entry in dr.async_entries_for_parent_device(
self._device_registry, device_id
):
self._add(ItemType.DEVICE, child_device_entry.id)
self._async_search_device(child_device_entry.id, entry_point=False)
@callback
def _async_add_automations_and_scripts_for_device(
self, device_entry: dr.DeviceEntry
self, device_entry: dr.AnyDeviceEntry
) -> None:
"""Add automations and scripts referencing a device.
@@ -335,7 +350,10 @@ class Searcher:
references to a sibling are not matched.
"""
device_ids = {device_entry.id}
if device_entry.composite_device_id is not None:
if (
isinstance(device_entry, dr.DeviceEntry)
and device_entry.composite_device_id is not None
):
device_ids.add(device_entry.composite_device_id)
for device_id in device_ids:
self._add(
@@ -590,22 +608,32 @@ class Searcher:
)
@callback
def _async_resolve_up_device(self, device_id: str) -> dr.DeviceEntry | None:
def _async_resolve_up_device(self, device_id: str) -> dr.AnyDeviceEntry | None:
"""Resolve up from a device.
Above a device is an area or floor.
Above a device is also the config entry.
Above a child device is also its parent device.
"""
if device_entry := self._device_registry.async_get(device_id):
if device_entry.area_id:
self._add(ItemType.AREA, device_entry.area_id)
self._async_resolve_up_area(device_entry.area_id)
if area_id := dr.async_get_effective_area_id(self.hass, device_entry):
self._add(ItemType.AREA, area_id)
self._async_resolve_up_area(area_id)
self._add(ItemType.CONFIG_ENTRY, device_entry.config_entries)
for config_entry_id in device_entry.config_entries:
if entry := self.hass.config_entries.async_get_entry(config_entry_id):
self._add(ItemType.INTEGRATION, entry.domain)
# A child device is contained by its parent. Unlike the informational
# via_device link (deliberately not followed here), the parent/child
# relation is first-class, so the parent is resolved up like the area and
# config entry. The parent is not fully searched, to avoid pulling in its
# unrelated sibling children.
if isinstance(device_entry, dr.ChildDeviceEntry):
self._add(ItemType.DEVICE, device_entry.parent_device_id)
self._async_resolve_up_device(device_entry.parent_device_id)
return device_entry
@callback
@@ -625,9 +653,9 @@ class Searcher:
elif entity_entry.device_id and (
device_entry := self._device_registry.async_get(entity_entry.device_id)
):
if device_entry.area_id:
self._add(ItemType.AREA, device_entry.area_id)
self._async_resolve_up_area(device_entry.area_id)
if area_id := dr.async_get_effective_area_id(self.hass, device_entry):
self._add(ItemType.AREA, area_id)
self._async_resolve_up_area(area_id)
# Add device that provided this entity
self._add(ItemType.DEVICE, entity_entry.device_id)
@@ -122,7 +122,9 @@ def _async_get_system_for_service_call(call: ServiceCall) -> SystemType:
device_registry = dr.async_get(call.hass)
if (
alarm_control_panel_device_entry := device_registry.async_get(device_id)
alarm_control_panel_device_entry := device_registry.async_get(
device_id, include_child_devices=False
)
) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
@@ -44,7 +44,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -666,7 +666,7 @@ class StatisticsSensor(SensorEntity):
samples_keep_last: bool,
precision: int,
percentile: int,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the Statistics sensor."""
self._attr_name: str = name
@@ -123,7 +123,6 @@ async def async_migrate_entry(
for device_entry in dr.async_entries_for_config_entry(
dev_reg, config_entry.entry_id
):
assert isinstance(device_entry, dr.DeviceEntry)
for identifier in device_entry.identifiers:
if match := re.match(
rf"(?P<id>.+)-{old_unique_id}$", identifier[1]
@@ -1,5 +1,6 @@
"""Base entity for Telegram bot integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
from . import TelegramBotConfigEntry, bot_device_info
@@ -9,6 +10,7 @@ class TelegramBotEntity(Entity):
"""Base entity."""
_attr_has_entity_name = True
_attr_device_info: DeviceInfo | None = None
def __init__(
self,
@@ -70,7 +70,11 @@ def async_get_device_for_service_call(
"""Get the device entry related to a service call."""
device_id = call.data[CONF_DEVICE_ID]
device_registry = dr.async_get(hass)
if (device_entry := device_registry.async_get(device_id)) is None:
if (
device_entry := device_registry.async_get(
device_id, include_child_devices=False
)
) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device",
@@ -30,7 +30,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -178,7 +178,7 @@ class ThresholdSensor(BinarySensorEntity):
hysteresis: float,
device_class: BinarySensorDeviceClass | None,
unique_id: str | None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the Threshold sensor."""
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
@@ -32,7 +32,7 @@ from homeassistant.const import (
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -179,7 +179,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
unique_id: str | None = None,
device_class: BinarySensorDeviceClass | None = None,
sensor_entity_id: str | None = None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize the sensor."""
self._entity_id = entity_id
+3 -1
View File
@@ -54,7 +54,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) -> None:
"""Try to get wireless client to reconnect to Wi-Fi."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(data[ATTR_DEVICE_ID])
device_entry = device_registry.async_get(
data[ATTR_DEVICE_ID], include_child_devices=False
)
if device_entry is None:
raise ServiceValidationError(
@@ -111,6 +111,9 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl
translation_placeholders={"device_id": device_id},
)
if isinstance(device_entry, dr.ChildDeviceEntry):
return _async_get_ufp_instance(hass, device_entry.parent_device_id)
if device_entry.via_device_id is not None:
return _async_get_ufp_instance(hass, device_entry.via_device_id)
@@ -159,7 +159,9 @@ class ProtectProxyView(HomeAssistantView):
device_registry = dr.async_get(self.hass)
if (entity := entity_registry.async_get(camera_id)) is None or (
device := device_registry.async_get(entity.device_id or "")
device := device_registry.async_get(
entity.device_id or "", include_child_devices=False
)
) is None:
return None
+1 -2
View File
@@ -129,7 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
connections.append((dr.CONNECTION_NETWORK_MAC, device_mac_address))
dev_registry = dr.async_get(hass)
device_entry = None
device_entry: dr.DeviceEntry | None = None
for identifier in identifiers:
if device_entry := dev_registry.async_get_device_by_identifier(
identifier, entry.entry_id
@@ -161,7 +161,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
"Created device using UDN '%s', device_entry: %s", device.udn, device_entry
)
else:
# Update identifier.
device_entry = dev_registry.async_update_device(
device_entry.id,
new_identifiers=set(identifiers),
@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import AnyDeviceEntry
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -94,7 +94,7 @@ class TariffSelect(SelectEntity, RestoreEntity):
*,
yaml_slug: str | None = None,
unique_id: str | None = None,
device: DeviceEntry | None = None,
device: AnyDeviceEntry | None = None,
) -> None:
"""Initialize a tariff selector."""
self._attr_name = name
+1 -1
View File
@@ -19,7 +19,7 @@ def async_get_device_entry_by_device_id(
Raises ValueError if device ID is invalid.
"""
device_reg = dr.async_get(hass)
if (device := device_reg.async_get(device_id)) is None:
if (device := device_reg.async_get(device_id, include_child_devices=False)) is None:
raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.")
return device
@@ -364,7 +364,7 @@ def _async_trigger_model_data(
) -> TriggerModelData | None:
"""Get available triggers for a given model."""
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
device = device_registry.async_get(device_id, include_child_devices=False)
if device and device.model and (model_data := MODEL_DATA.get(device.model)):
return model_data
return None
@@ -72,7 +72,7 @@ async def async_get_triggers(
) -> list[dict[str, Any]]:
"""List device triggers for YoLink devices."""
device_registry = dr.async_get(hass)
registry_device = device_registry.async_get(device_id)
registry_device = device_registry.async_get(device_id, include_child_devices=False)
if not registry_device or registry_device.model not in [
ATTR_DEVICE_SMART_REMOTER,
ATTR_DEVICE_SWITCH,
+4 -2
View File
@@ -444,7 +444,9 @@ class ZHADeviceProxy(EventBase):
if reg_device is not None:
device_info[USER_GIVEN_NAME] = reg_device.name_by_user
device_info[DEVICE_REG_ID] = reg_device.id
device_info[ATTR_AREA_ID] = reg_device.area_id
device_info[ATTR_AREA_ID] = dr.async_get_effective_area_id(
self.gateway_proxy.hass, reg_device
)
return device_info
@callback
@@ -642,7 +644,7 @@ class ZHAGatewayProxy(EventBase):
or entity_entry.device_id is None
):
return
device_entry: dr.DeviceEntry | None = dr.async_get(self.hass).async_get(
device_entry: dr.AnyDeviceEntry | None = dr.async_get(self.hass).async_get(
entity_entry.device_id
)
assert device_entry
+2 -2
View File
@@ -287,7 +287,7 @@ def async_get_node_from_device_id(
if not dev_reg:
dev_reg = dr.async_get(hass)
if not (device_entry := dev_reg.async_get(device_id)):
if not (device_entry := dev_reg.async_get(device_id, include_child_devices=False)):
raise ValueError(f"Device ID {device_id} is not valid")
# Use device config entry ID's to validate that this is a valid zwave_js device
@@ -523,7 +523,7 @@ def async_get_node_status_sensor_entity_id(
ent_reg = er.async_get(hass)
if not dev_reg:
dev_reg = dr.async_get(hass)
if not (device := dev_reg.async_get(device_id)):
if not (device := dev_reg.async_get(device_id, include_child_devices=False)):
raise HomeAssistantError("Invalid Device ID provided")
if not (entry_id := _zwave_js_config_entry(hass, device)):
+1 -1
View File
@@ -26,7 +26,7 @@ def async_entity_id_to_device_id(
def async_entity_id_to_device(
hass: HomeAssistant,
entity_id_or_uuid: str,
) -> dr.DeviceEntry | None:
) -> dr.AnyDeviceEntry | None:
"""Resolve the device entry for the entity id or entity uuid."""
if (device_id := async_entity_id_to_device_id(hass, entity_id_or_uuid)) is None:
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -57,7 +57,7 @@ from homeassistant.util import ensure_unique_string, slugify
from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed
from . import device_registry as dr, entity_registry as er
from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData
from .device_registry import ChildDeviceInfo, DeviceInfo, EventDeviceRegistryUpdatedData
from .event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
@@ -529,7 +529,7 @@ class Entity(
_removed_from_registry: bool = False
# The device entry for this entity
device_entry: dr.DeviceEntry | None = None
device_entry: dr.AnyDeviceEntry | None = None
# Cached friendly name as (original_name, computed_friendly_name)
# Invalidated on relevant registry changes
@@ -575,7 +575,7 @@ class Entity(
_attr_available: bool = True
_attr_capability_attributes: dict[str, Any] | None = None
_attr_device_class: str | None
_attr_device_info: DeviceInfo | None = None
_attr_device_info: DeviceInfo | ChildDeviceInfo | None = None
_attr_entity_category: EntityCategory | None
_attr_has_entity_name: bool
_attr_entity_picture: str | None = None
@@ -828,7 +828,7 @@ class Entity(
return None
@cached_property
def device_info(self) -> DeviceInfo | None:
def device_info(self) -> DeviceInfo | ChildDeviceInfo | None:
"""Return device specific attributes.
Implemented by platform classes.
+27 -7
View File
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
from contextvars import ContextVar
from datetime import timedelta
from logging import Logger, getLogger
from typing import TYPE_CHECKING, Any, Protocol, overload, override
from typing import TYPE_CHECKING, Any, Protocol, cast, overload, override
from homeassistant import config_entries
from homeassistant.const import (
@@ -948,15 +948,35 @@ class EntityPlatform:
entity.add_to_platform_abort()
return
device: dr.DeviceEntry | None
device: dr.AnyDeviceEntry | None
if self.config_entry:
if device_info := entity.device_info:
dev_reg = dr.async_get(self.hass)
try:
device = dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**device_info,
)
# A device info carrying a parent_device_id registers a child
# device. An explicit None (as a dynamically built device info
# may carry) means a main device, so check `is not None`.
if device_info.get("parent_device_id") is not None:
device = dev_reg.async_get_or_create_child(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**cast("dr.ChildDeviceInfo", device_info),
)
else:
# An explicit parent_device_id=None means a main device;
# drop the key as async_get_or_create is main-only.
device = dev_reg.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**cast(
"dr.DeviceInfo",
{
key: value
for key, value in device_info.items()
if key != "parent_device_id"
},
),
)
except dr.DeviceInfoError as exc:
self.logger.error(
"%s: Not adding entity with invalid device info: %s",
+38 -13
View File
@@ -515,14 +515,13 @@ def _async_get_full_entity_name(
elif not use_legacy_naming or name is None:
device_name: str | None = None
if (
device_id is not None
and (device := dr.async_get(hass).async_get(device_id)) is not None
):
device_name = device.name_by_user or device.name
if device_id is not None:
device_registry = dr.async_get(hass)
if (device := device_registry.async_get(device_id)) is not None:
device_name = device.name_by_user or device.name
if area_id is None:
area_id = device.area_id
if area_id is None:
area_id = dr.async_get_effective_area_id(hass, device)
area_name: str | None = None
floor_name: str | None = None
@@ -1168,7 +1167,10 @@ 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:
if (
device_id not in device_registry.devices
and device_id not in device_registry.child_devices
):
raise ValueError(f"Device {device_id} does not exist")
if (
disabled_by
@@ -1684,11 +1686,10 @@ class EntityRegistry(BaseRegistry):
)
removed_device_dict = event.data["device"]
for entity in entities:
config_entry_id = entity.config_entry_id
if (
config_entry_id in removed_device_dict["config_entries"]
entity.config_entry_id == removed_device_dict["config_entry_id"]
and entity.config_subentry_id
in removed_device_dict["config_entries_subentries"][config_entry_id]
== removed_device_dict["config_subentry_id"]
):
self.async_remove(entity.entity_id)
else:
@@ -2176,8 +2177,13 @@ class EntityRegistry(BaseRegistry):
) -> 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
if device_id is None or device_id in device_registry.devices:
# 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
):
return device_id
successors = device_registry.async_get_devices_for_composite_device_id(
device_id
@@ -2516,6 +2522,25 @@ def async_entries_for_area(
return registry.entities.get_entries_for_area_id(area_id)
@callback
def async_get_effective_area_id(
hass: HomeAssistant, entry: RegistryEntry
) -> str | None:
"""Return the effective area of an entity.
An entity without an area of its own inherits its device's effective area
(which a child device in turn inherits from its parent device).
"""
if entry.area_id is not None:
return entry.area_id
if entry.device_id is None:
return None
device_registry = dr.async_get(hass)
if (device := device_registry.async_get(entry.device_id)) is None:
return None
return dr.async_get_effective_area_id(hass, device)
@callback
def async_entries_for_label(
registry: EntityRegistry, label_id: str
+19 -6
View File
@@ -187,11 +187,20 @@ def async_remove_helper_devices(
return
# source_device_id is either the pre-migration composite id (source_device is then the
# synthesized composite) or a concrete device. Its splits, if any, share this id as
# their composite_device_id.
source_is_concrete = source_device_id in device_registry.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
)
composite_device_id = (
source_device.composite_device_id if source_is_concrete else source_device_id
(
source_device.composite_device_id
if isinstance(source_device, dr.DeviceEntry)
else None
)
if source_is_concrete
else source_device_id
)
target_device_id = source_device_id if source_is_concrete else None
@@ -218,7 +227,7 @@ def _remove_duplicate_helper_device(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
helper_config_entry_id: str,
source_device: dr.DeviceEntry,
source_device: dr.AnyDeviceEntry,
composite_device_id: str | None,
target_device_id: str | None,
) -> None:
@@ -240,7 +249,11 @@ def _remove_duplicate_helper_device(
and device.composite_device_id == composite_device_id
)
or device.identifiers & source_device.identifiers
or device.connections & source_device.connections
# A child source device has no connections to match on.
or (
isinstance(source_device, dr.DeviceEntry)
and device.connections & source_device.connections
)
),
None,
)
+7 -3
View File
@@ -370,7 +370,7 @@ class MatchTargetsCandidate:
is_exposed: bool
entity: er.RegistryEntry | None = None
area: ar.AreaEntry | None = None
device: dr.DeviceEntry | None = None
device: dr.AnyDeviceEntry | None = None
matched_name: str | None = None
@@ -494,9 +494,13 @@ def _add_areas(
# Use entity area first
candidate.area = areas.async_get_area(candidate.entity.area_id)
assert candidate.area is not None
elif (candidate.device is not None) and candidate.device.area_id:
elif candidate.device is not None and (
device_area_id := dr.async_get_effective_area_id(
devices.hass, candidate.device
)
):
# Fall back to device area
candidate.area = areas.async_get_area(candidate.device.area_id)
candidate.area = areas.async_get_area(device_area_id)
def _default_area_candidate_filter(
+3 -1
View File
@@ -264,7 +264,9 @@ class IntentTool(Tool):
floor: fr.FloorEntry | None = None
if device:
area_reg = ar.async_get(hass)
if device.area_id and (area := area_reg.async_get_area(device.area_id)):
if (
device_area_id := dr.async_get_effective_area_id(hass, device)
) and (area := area_reg.async_get_area(device_area_id)):
if area.floor_id:
floor_reg = fr.async_get(hass)
floor = floor_reg.async_get_floor(area.floor_id)
+3 -4
View File
@@ -430,10 +430,9 @@ 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
and (device := dev_reg.async_get(device_id)) is not None
):
if (device_id in dev_reg.devices or device_id in dev_reg.child_devices) and (
device := dev_reg.async_get(device_id)
) is not None:
config_entry_ids.update(device.config_entries)
for entity_id in referenced.referenced | referenced.indirectly_referenced:
+48 -17
View File
@@ -155,6 +155,45 @@ class SelectedEntities:
)
@callback
def _resolve_referenced_devices(
dev_reg: dr.DeviceRegistry, device_ids: set[str], selected: SelectedEntities
) -> None:
"""Resolve targeted device ids into referenced device ids."""
for device_id in device_ids:
if device_id in dev_reg.devices:
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:
selected.referenced_devices.add(device_id)
elif split_devices := dev_reg.async_get_devices_for_composite_device_id(
device_id
):
# A multi config entry composite device id is no longer a device itself;
# it resolves to the devices it was split into so actions targeting it
# still trickle down. Only the splits are referenced, not the composite id,
# so a device-id consumer does not act on the same underlying device twice.
# Each split's children are included too, matching the direct-device branch.
for split_device in split_devices:
selected.referenced_devices.add(split_device.id)
selected.referenced_devices.update(
child_device.id
for child_device in (
dev_reg.child_devices.get_children_for_device_id(
split_device.id
)
)
)
else:
selected.missing_devices.add(device_id)
selected.referenced_devices.add(device_id)
def async_extract_referenced_entity_ids(
hass: HomeAssistant,
target_selection: TargetSelection,
@@ -205,20 +244,7 @@ def async_extract_referenced_entity_ids(
if area_id not in area_reg.areas:
selected.missing_areas.add(area_id)
for device_id in target_selection.device_ids:
if device_id in dev_reg.devices:
selected.referenced_devices.add(device_id)
elif split_devices := dev_reg.async_get_devices_for_composite_device_id(
device_id
):
# A multi config entry composite device id is no longer a device itself;
# it resolves to the devices it was split into so actions targeting it
# still trickle down. Only the splits are referenced, not the composite id,
# so a device-id consumer does not act on the same underlying device twice.
selected.referenced_devices.update(device.id for device in split_devices)
else:
selected.missing_devices.add(device_id)
selected.referenced_devices.add(device_id)
_resolve_referenced_devices(dev_reg, target_selection.device_ids, selected)
if target_selection.label_ids:
label_reg = lr.async_get(hass)
@@ -230,7 +256,11 @@ def async_extract_referenced_entity_ids(
if entity_entry.hidden_by is None:
selected.indirectly_referenced.add(entity_entry.entity_id)
for device_entry in dev_reg.devices.get_devices_for_label(label_id):
# Labels are never inherited by child devices (see
# dr.async_entries_for_label): a labeled parent is not expanded into its
# children. Only devices that carry the label themselves are targeted,
# which is consistent with template label_devices() and search.
for device_entry in dr.async_entries_for_label(dev_reg, label_id):
selected.referenced_devices.add(device_entry.id)
for area_entry in area_reg.areas.get_areas_for_label(label_id):
@@ -269,7 +299,7 @@ def async_extract_referenced_entity_ids(
for area_id in selected.referenced_areas:
referenced_devices_by_area.update(
device_entry.id
for device_entry in dev_reg.devices.get_devices_for_area_id(area_id)
for device_entry in dr.async_entries_for_area(dev_reg, area_id)
)
selected.referenced_devices.update(referenced_devices_by_area)
@@ -346,7 +376,8 @@ class TargetEntityChangeTracker(abc.ABC):
# Subscribe to registry updates that can change the entities to track:
# - Entity registry: entity added/removed;
# entity labels changed; entity area changed.
# - Device registry: device labels changed; device area changed.
# - Device registry: device labels changed; device area changed;
# child device added/removed under a targeted parent.
# - Area registry: area floor changed.
#
# We don't track other registries (like floor or label registries) because their
@@ -105,12 +105,14 @@ class AreaExtension(BaseTemplateExtension):
if (
entity.device_id
and (device := dev_reg.async_get(entity.device_id))
and device.area_id
and (area_id := dr.async_get_effective_area_id(self.hass, device))
):
return self._get_area_name(area_reg, device.area_id)
return self._get_area_name(area_reg, area_id)
if (device := dev_reg.async_get(lookup_value)) and device.area_id:
return self._get_area_name(area_reg, device.area_id)
if (device := dev_reg.async_get(lookup_value)) and (
area_id := dr.async_get_effective_area_id(self.hass, device)
):
return self._get_area_name(area_reg, area_id)
return None
@@ -85,7 +85,8 @@ class DeviceExtension(BaseTemplateExtension):
return next(
(
device_id
for device_id, device in dev_reg.devices.items()
for container in (dev_reg.devices, dev_reg.child_devices)
for device_id, device in container.items()
if (name := device.name_by_user or device.name)
and (str(entity_id_or_device_name) == name)
),
+3 -3
View File
@@ -58,13 +58,13 @@ def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
# If entity has an area ID, return that
if entity.area_id:
return entity.area_id
# If entity has a device ID, return the area ID for the device
# If entity has a device ID, return the effective area of the device
if entity.device_id and (device := dev_reg.async_get(entity.device_id)):
return device.area_id
return dr.async_get_effective_area_id(hass, device)
# Check if it's a device ID
if device := dev_reg.async_get(lookup_value):
return device.area_id
return dr.async_get_effective_area_id(hass, device)
return None
+6 -2
View File
@@ -406,9 +406,13 @@ class ComponentProtocol(Protocol):
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: dr.DeviceEntry,
device_entry: dr.AnyDeviceEntry,
) -> bool:
"""Remove a config entry device."""
"""Remove a config entry device.
Only integrations that register child devices can receive a
ChildDeviceEntry. Removing a parent device also removes its child devices.
"""
async def async_reset_platform(
self, hass: HomeAssistant, integration_name: str
+97 -1
View File
@@ -9,7 +9,7 @@ from homeassistant.auth.permissions.entities import (
)
from homeassistant.auth.permissions.models import PermissionLookup
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import ChildDeviceEntry, DeviceEntry
from tests.common import RegistryEntryWithDefaults, mock_device_registry, mock_registry
@@ -223,3 +223,99 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None:
assert compiled("light.kitchen", "control") is True
assert compiled("light.kitchen", "edit") is False
assert compiled("switch.kitchen", "read") is False
def test_entities_areas_area_inherited_from_parent(hass: HomeAssistant) -> None:
"""Test area policy for an entity on a child inheriting the parent's area."""
entity_registry = mock_registry(
hass,
{
"light.kitchen": RegistryEntryWithDefaults(
entity_id="light.kitchen",
unique_id="1234",
platform="test_platform",
device_id="mock-child-id",
)
},
)
device_registry = mock_device_registry(
hass,
{
"mock-parent-id": DeviceEntry(
config_entry_id="mock-config-entry",
id="mock-parent-id",
area_id="mock-area-id",
)
},
)
# The child has no area of its own and inherits the parent's area.
device_registry.child_devices["mock-child-id"] = ChildDeviceEntry(
config_entry_id="mock-config-entry",
id="mock-child-id",
parent_device_id="mock-parent-id",
)
policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}}
ENTITY_POLICY_SCHEMA(policy)
compiled = compile_entities(
policy, PermissionLookup(entity_registry, device_registry)
)
assert compiled("light.kitchen", "read") is True
assert compiled("light.kitchen", "control") is True
assert compiled("light.kitchen", "edit") is False
assert compiled("switch.kitchen", "read") is False
def test_entities_areas_device_not_found(hass: HomeAssistant) -> None:
"""Test area policy denies when the entity's device is missing from the registry."""
entity_registry = mock_registry(
hass,
{
"light.kitchen": RegistryEntryWithDefaults(
entity_id="light.kitchen",
unique_id="1234",
platform="test_platform",
device_id="mock-dev-id",
)
},
)
device_registry = mock_device_registry(hass, {})
policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}}
ENTITY_POLICY_SCHEMA(policy)
compiled = compile_entities(
policy, PermissionLookup(entity_registry, device_registry)
)
assert compiled("light.kitchen", "read") is False
def test_entities_areas_device_without_effective_area(hass: HomeAssistant) -> None:
"""Test area policy denies when the entity's device has no effective area."""
entity_registry = mock_registry(
hass,
{
"light.kitchen": RegistryEntryWithDefaults(
entity_id="light.kitchen",
unique_id="1234",
platform="test_platform",
device_id="mock-dev-id",
)
},
)
device_registry = mock_device_registry(
hass,
{
"mock-dev-id": DeviceEntry(
config_entry_id="mock-config-entry",
id="mock-dev-id",
area_id=None,
)
},
)
policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}}
ENTITY_POLICY_SCHEMA(policy)
compiled = compile_entities(
policy, PermissionLookup(entity_registry, device_registry)
)
assert compiled("light.kitchen", "read") is False
+2
View File
@@ -761,6 +761,8 @@ def mock_device_registry(
registry = dr.DeviceRegistry(hass)
registry.devices = dr.ActiveDeviceRegistryItems()
registry._device_data = registry.devices.data
registry.child_devices = dr.ChildDeviceRegistryItems()
registry._child_device_data = registry.child_devices.data
if mock_entries is None:
mock_entries = {}
for key, entry in mock_entries.items():
@@ -1364,6 +1364,87 @@ async def test_devices_payload_with_entities(
}
async def test_devices_payload_with_child_device(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test devices payload reports child devices and attributes their entities."""
assert await async_setup_component(hass, DOMAIN, {})
mock_config_entry = MockConfigEntry(domain="hue")
mock_config_entry.add_to_hass(hass)
parent = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={("device", "parent")},
manufacturer="test-manufacturer",
model_id="test-model-id",
)
child = device_registry.async_get_or_create_child(
config_entry_id=mock_config_entry.entry_id,
identifiers={("device", "child")},
parent_device_id=parent.id,
name="Child device",
)
# Entity attached to the child device
entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="child-1",
device_id=child.id,
has_entity_name=True,
)
client = await hass_client()
response = await client.get("/api/analytics/devices")
assert response.status == HTTPStatus.OK
assert await response.json() == {
"version": "home-assistant:1",
"home_assistant": MOCK_VERSION,
"integrations": {
"hue": {
"devices": [
{
"entry_type": None,
"has_configuration_url": False,
"hw_version": None,
"manufacturer": "test-manufacturer",
"model": None,
"model_id": "test-model-id",
"sw_version": None,
"via_device": None,
"entities": [],
},
{
"entry_type": None,
"has_configuration_url": False,
"hw_version": None,
"manufacturer": None,
"model": None,
"model_id": None,
"sw_version": None,
"via_device": ["hue", 0],
"entities": [
{
"assumed_state": None,
"domain": "light",
"entity_category": None,
"has_entity_name": True,
"original_device_class": None,
"unit_of_measurement": None,
},
],
},
],
"entities": [],
},
},
}
async def test_analytics_platforms(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -2036,6 +2036,91 @@ async def test_acknowledge(
assert has_acknowledge_override
@pytest.mark.parametrize(("use_satellite_entity"), [True, False])
async def test_acknowledge_child_device_inherits_area(
hass: HomeAssistant,
init_components,
pipeline_data: assist_pipeline.pipeline.PipelineData,
mock_chat_session: chat_session.ChatSession,
entity_registry: er.EntityRegistry,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
use_satellite_entity: bool,
) -> None:
"""Test acknowledge works when satellite and target inherit area from a parent."""
area_1 = area_registry.async_get_or_create("area_1")
entry = MockConfigEntry()
entry.add_to_hass(hass)
# Parent device carries the area; its children have no area of their own and
# inherit the parent's.
parent_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("demo", "parent")},
)
device_registry.async_update_device(parent_device.id, area_id=area_1.id)
satellite_device = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={("demo", "satellite-child")},
parent_device_id=parent_device.id,
)
satellite = entity_registry.async_get_or_create(
"assist_satellite", "test", "1234", device_id=satellite_device.id
)
light_device = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={("demo", "light-child")},
parent_device_id=parent_device.id,
)
light_1 = entity_registry.async_get_or_create(
"light", "demo", "1234", original_name="light 1", device_id=light_device.id
)
hass.states.async_set(light_1.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 1"})
turn_on = async_mock_service(hass, "light", "turn_on")
pipeline_store = pipeline_data.pipeline_store
pipeline_id = pipeline_store.async_get_preferred_item()
pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id)
events: list[assist_pipeline.PipelineEvent] = []
async def _run(text: str) -> None:
pipeline_input = assist_pipeline.pipeline.PipelineInput(
intent_input=text,
session=mock_chat_session,
satellite_id=satellite.entity_id if use_satellite_entity else None,
device_id=satellite_device.id if not use_satellite_entity else None,
run=assist_pipeline.pipeline.PipelineRun(
hass,
context=Context(),
pipeline=pipeline,
start_stage=assist_pipeline.PipelineStage.INTENT,
end_stage=assist_pipeline.PipelineStage.TTS,
event_callback=events.append,
),
)
await pipeline_input.validate()
await pipeline_input.execute()
with patch(
"homeassistant.components.assist_pipeline.PipelineRun.text_to_speech"
) as text_to_speech:
await _run("turn on light 1")
# Acknowledgment sound is played: the satellite and the light both inherit
# area_1 from their parent device, so all targets are in the satellite area.
text_to_speech.assert_called_once()
assert (
text_to_speech.call_args.kwargs["override_media_path"] == ACKNOWLEDGE_PATH
)
assert len(turn_on) == 1
async def test_acknowledge_other_agents(
hass: HomeAssistant,
init_components,
@@ -639,6 +639,75 @@ async def test_async_step_integration_discovery_remote_adapter(
await hass.async_block_till_done()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_step_integration_discovery_remote_adapter_child_source(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
area_registry: ar.AreaRegistry,
) -> None:
"""Test remote adapter whose source is a child device.
A child device can't be a via device, so the scanner is linked to the child's
parent, while still inheriting the source's (inherited) effective area.
"""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
connector = (
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
)
scanner = FakeRemoteScanner("esp32", "esp32", connector, True)
manager = _get_manager()
area_entry = area_registry.async_get_or_create("test")
cancel_scanner = manager.async_register_scanner(scanner)
parent_device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "BB:BB:BB:BB:BB:BB")},
suggested_area=area_entry.id,
)
child_device_entry = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={("test", "BB:BB:BB:BB:BB:BB-child")},
parent_device_id=parent_device_entry.id,
name="child",
)
# The child inherits its parent's area rather than owning one.
assert child_device_entry.area_id is None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
CONF_SOURCE: scanner.source,
CONF_SOURCE_DOMAIN: "test",
CONF_SOURCE_MODEL: "test",
CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id,
CONF_SOURCE_DEVICE_ID: child_device_entry.id,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
new_entry_id: str = result["result"].entry_id
new_entry = hass.config_entries.async_get_entry(new_entry_id)
assert new_entry is not None
assert new_entry.state is config_entries.ConfigEntryState.LOADED
ble_device_entry = device_registry.async_get_device_by_connection(
(dr.CONNECTION_BLUETOOTH, scanner.source), new_entry.entry_id
)
assert ble_device_entry is not None
# A child device can't be a via device, so the parent is used instead.
assert ble_device_entry.via_device_id == parent_device_entry.id
# The scanner still inherits the source child's effective (parent) area.
assert ble_device_entry.area_id == area_entry.id
await hass.config_entries.async_unload(new_entry.entry_id)
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
cancel_scanner()
await hass.async_block_till_done()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_step_integration_discovery_remote_adapter_mac_fix(
hass: HomeAssistant,
@@ -342,6 +342,59 @@ async def test_google_device_registry_sync(
assert len(mock_sync.mock_calls) == 1
@pytest.mark.usefixtures("mock_cloud_login")
async def test_google_device_registry_sync_child_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
cloud_prefs: CloudPreferences,
) -> None:
"""Test a parent area change syncs entities on area-inheriting children."""
config = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data[DATA_CLOUD]
)
# Enable exposing new entities to Google
expose_new(hass, True)
config_entry = MockConfigEntry(domain="test", data={})
config_entry.add_to_hass(hass)
parent_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
child_entry = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("test", "child")},
parent_device_id=parent_entry.id,
)
# Entity lives on the child device, which has no area of its own and so
# inherits the parent's area.
entity_registry.async_get_or_create(
"light", "hue", "1234", device_id=child_entry.id
)
with patch.object(config, "async_sync_entities_all"):
await config.async_initialize()
await hass.async_block_till_done()
await config.async_connect_agent_user("mock-user-id")
await hass.async_block_till_done()
with patch.object(config, "async_schedule_google_sync_all") as mock_sync:
# The parent area changed, changing the child entity's effective area
hass.bus.async_fire(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
{
"action": "update",
"device_id": parent_entry.id,
"changes": ["area_id"],
},
)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
@pytest.mark.usefixtures("mock_cloud_login")
async def test_sync_google_when_started(
hass: HomeAssistant, cloud_prefs: CloudPreferences
@@ -78,6 +78,7 @@ async def test_list_devices(
"modified_at": utcnow().timestamp(),
"name_by_user": None,
"name": None,
"parent_device_id": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
@@ -103,6 +104,7 @@ async def test_list_devices(
"modified_at": utcnow().timestamp(),
"name_by_user": None,
"name": None,
"parent_device_id": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
@@ -141,6 +143,7 @@ async def test_list_devices(
"modified_at": utcnow().timestamp(),
"name_by_user": None,
"name": None,
"parent_device_id": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
@@ -885,3 +888,290 @@ async def test_list_linked_devices_unknown_device(
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
assert msg["error"]["message"] == "Device not found"
def _create_parent_and_child(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
*,
domain: str = "test",
) -> tuple[MockConfigEntry, dr.DeviceEntry, dr.ChildDeviceEntry]:
"""Create a config entry with a parent device and one child device."""
entry = MockConfigEntry(domain=domain, title="Test")
entry.add_to_hass(hass)
parent = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(domain, "strip")},
name="Power strip",
)
child_device = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={(domain, "strip_outlet_1")},
parent_device_id=parent.id,
name="Outlet 1",
)
return entry, parent, child_device
async def test_list_devices_with_child_devices(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test child devices are included in the device list."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
entry, parent, child_device = _create_parent_and_child(hass, device_registry)
await client.send_json_auto_id({"type": "config/device_registry/list"})
msg = await client.receive_json()
assert msg["result"] == [
{
"area_id": None,
"config_entries": [entry.entry_id],
"config_entries_subentries": {entry.entry_id: [None]},
"config_entry_id": entry.entry_id,
"config_subentry_id": None,
"configuration_url": None,
"connections": [],
"created_at": parent.created_at.timestamp(),
"disabled_by": None,
"entry_type": None,
"hw_version": None,
"id": parent.id,
"identifiers": [["test", "strip"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": parent.modified_at.timestamp(),
"name_by_user": None,
"name": "Power strip",
"parent_device_id": None,
"primary_config_entry": entry.entry_id,
"serial_number": None,
"sw_version": None,
"via_device_id": None,
},
{
"area_id": None,
"config_entry_id": entry.entry_id,
"config_subentry_id": None,
"created_at": child_device.created_at.timestamp(),
"disabled_by": None,
"id": child_device.id,
"identifiers": [["test", "strip_outlet_1"]],
"labels": [],
"modified_at": child_device.modified_at.timestamp(),
"name_by_user": None,
"name": "Outlet 1",
"parent_device_id": parent.id,
},
]
@pytest.mark.parametrize(
("payload_key", "payload_value", "expected_registry_value"),
[
pytest.param("area_id", "garden", "garden", id="area_id"),
pytest.param("labels", ["label1"], {"label1"}, id="labels"),
pytest.param("name_by_user", "Garden lamp", "Garden lamp", id="name_by_user"),
pytest.param(
"disabled_by", "user", dr.DeviceEntryDisabler.USER, id="disabled_by"
),
],
)
async def test_update_child_device(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
payload_key: str,
payload_value: Any,
expected_registry_value: Any,
) -> None:
"""Test updating a child device through the websocket API."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
_, _, child_device = _create_parent_and_child(hass, device_registry)
await client.send_json_auto_id(
{
"type": "config/device_registry/update",
"device_id": child_device.id,
payload_key: payload_value,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"][payload_key] == payload_value
assert msg["result"]["parent_device_id"] == child_device.parent_device_id
# The update reached the registry entry, not just the websocket response
updated_child = device_registry.async_get(
child_device.id, include_main_devices=False
)
assert updated_child is not None
assert getattr(updated_child, payload_key) == expected_registry_value
async def test_update_child_device_area_round_trip(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test overriding and re-inheriting a child device area via the API."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
_, parent, child_device = _create_parent_and_child(hass, device_registry)
device_registry.async_update_device(parent.id, area_id="garage")
await client.send_json_auto_id(
{
"type": "config/device_registry/update",
"device_id": child_device.id,
"area_id": "garden",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["area_id"] == "garden"
# Clearing the area restores inheriting the parent's area
await client.send_json_auto_id(
{
"type": "config/device_registry/update",
"device_id": child_device.id,
"area_id": None,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["area_id"] is None
updated_child = device_registry.async_get(
child_device.id, include_main_devices=False
)
assert updated_child is not None
assert dr.async_get_effective_area_id(hass, updated_child) == "garage"
async def test_remove_config_entry_from_child_device(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test removing a child device via the websocket API."""
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
can_remove = False
removed_devices: list[str] = []
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: dr.DeviceEntry | dr.ChildDeviceEntry,
) -> bool:
removed_devices.append(device_entry.id)
return can_remove
mock_integration(
hass,
MockModule(
"comp1", async_remove_config_entry_device=async_remove_config_entry_device
),
)
entry, parent, child_device = _create_parent_and_child(
hass, device_registry, domain="comp1"
)
entry.supports_remove_device = True
# Rejected by the integration
response = await ws_client.remove_device(child_device.id)
assert not response["success"]
assert response["error"]["code"] == "home_assistant_error"
assert removed_devices == [child_device.id]
can_remove = True
removed_devices.clear()
# The integration hook receives the child device entry
response = await ws_client.remove_device(child_device.id)
assert response["success"]
assert removed_devices == [child_device.id]
assert device_registry.async_get(child_device.id) is None
assert device_registry.async_get(parent.id) is not None
async def test_remove_config_entry_from_parent_with_children(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test removing a parent device consults the hook once and cascades."""
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
consulted_devices: list[str] = []
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: dr.DeviceEntry | dr.ChildDeviceEntry,
) -> bool:
consulted_devices.append(device_entry.id)
return True
mock_integration(
hass,
MockModule(
"comp1", async_remove_config_entry_device=async_remove_config_entry_device
),
)
entry, parent, child_device = _create_parent_and_child(
hass, device_registry, domain="comp1"
)
entry.supports_remove_device = True
response = await ws_client.remove_device(parent.id)
assert response["success"]
# The hook is consulted once, with the parent; child devices cascade
assert consulted_devices == [parent.id]
assert device_registry.async_get(parent.id) is None
assert device_registry.async_get(child_device.id) is None
async def test_list_linked_devices_child_device(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test a child device is never reported as linked.
A child shares its parent's per-config-entry identifier namespace, so even
when a main device of another config entry carries the same identifier the
child must still yield an empty result.
"""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
_, _, child_device = _create_parent_and_child(hass, device_registry)
# A main device of another config entry that shares the child's identifier
# would be surfaced if children were matched like main devices; it must not.
other_entry = MockConfigEntry()
other_entry.add_to_hass(hass)
other_device = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
identifiers={("test", "strip_outlet_1")},
)
assert other_device.identifiers == child_device.identifiers
await client.send_json_auto_id(
{
"type": "config/device_registry/list_linked_devices",
"device_id": child_device.id,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {"linked_devices": []}
+40 -1
View File
@@ -18,7 +18,10 @@ from homeassistant.components.binary_sensor.device_trigger import (
from homeassistant.components.deconz import device_trigger
from homeassistant.components.deconz.const import DOMAIN
from homeassistant.components.deconz.device_trigger import CONF_SUBTYPE
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.device_automation import (
DeviceAutomationType,
InvalidDeviceAutomationConfig,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
@@ -507,3 +510,39 @@ async def test_attach_trigger_no_matching_event(
name="mock-name",
log_cb=Mock(),
)
async def test_child_device_id_resolves_cleanly(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry_setup: MockConfigEntry,
) -> None:
"""Test a child device id is handled cleanly at both trigger sites.
A child device id is not in the main device dict, so bare dict access would
raise KeyError. Both async_get_triggers and async_attach_trigger must instead
resolve it as not-found.
"""
parent = device_registry.async_get_or_create(
config_entry_id=config_entry_setup.entry_id,
identifiers={(DOMAIN, "parent_device")},
name="Parent",
)
child = device_registry.async_get_or_create_child(
config_entry_id=config_entry_setup.entry_id,
identifiers={(DOMAIN, "child_device")},
parent_device_id=parent.id,
name="Child",
)
assert await device_trigger.async_get_triggers(hass, child.id) == []
trigger_config = {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: child.id,
CONF_TYPE: device_trigger.CONF_SHORT_PRESS,
CONF_SUBTYPE: device_trigger.CONF_TURN_ON,
}
with pytest.raises(InvalidDeviceAutomationConfig):
await device_trigger.async_attach_trigger(hass, trigger_config, Mock(), Mock())
@@ -1619,6 +1619,55 @@ async def test_register_mac_ignored(
assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
@pytest.mark.parametrize(
("mac_address", "unique_id"), [(TEST_MAC_ADDRESS, f"{TEST_MAC_ADDRESS}_yo1")]
)
async def test_register_mac_ignores_child_device_created(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
scanner_entity: MockScannerEntity,
entity_id: str,
mac_address: str,
unique_id: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the MAC listener skips a newly created child device.
Registering a scanner MAC installs a device-registry create listener. A child
device has no connections attribute, so the listener must resolve it to None
(include_child_devices=False) and skip it, instead of raising AttributeError
while reading connections.
"""
await create_mock_platform(hass, config_entry, [scanner_entity])
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry is not None
assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
caplog.clear()
parent = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(TEST_DOMAIN, "parent")},
)
device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={(TEST_DOMAIN, "child")},
parent_device_id=parent.id,
)
await hass.async_block_till_done()
# The listener must not have raised while handling the child's create event.
assert "Error running job" not in caplog.text
# A child device has no MAC, so the scanner entity stays disabled.
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry is not None
assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
@pytest.fixture
def allow_deprecated_device_registry_apis() -> Generator[None]:
"""Allow tests to call the deprecated device registry APIs without raising.
@@ -17,7 +17,11 @@ from homeassistant.components.google_assistant.const import (
from homeassistant.components.matter import MatterDeviceInfo
from homeassistant.core import HomeAssistant, State
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -122,6 +126,60 @@ async def test_google_entity_sync_serialize_with_matter(
assert serialized["matterOriginalProductId"] == "mock-product-id"
async def test_google_entity_sync_serialize_child_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
area_registry: ar.AreaRegistry,
) -> None:
"""Test an entity on a child device keeps its device and inherits its area."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
area = area_registry.async_create("Living Room")
parent = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "strip")},
manufacturer="Someone",
model="Some model",
sw_version="Some Version",
name="Power strip",
)
device_registry.async_update_device(parent.id, area_id=area.id)
child = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={("test", "strip_outlet_1")},
parent_device_id=parent.id,
name="Outlet 1",
)
entity_entry = entity_registry.async_get_or_create(
"light",
"test",
"1235",
suggested_object_id="ceiling_lights",
device_id=child.id,
)
hass.states.async_set("light.ceiling_lights", "off")
# The child device is resolved (async_get finds children, unlike the mains-only
# devices dict) and inherits the parent device's area.
_, device_entry, area_reg_entry = helpers._get_registry_entries(
hass, entity_entry.entity_id
)
assert device_entry is not None
assert device_entry.id == child.id
assert area_reg_entry is not None
assert area_reg_entry.id == area.id
entity = helpers.GoogleEntity(
hass, MockConfig(hass=hass), hass.states.get("light.ceiling_lights")
)
serialized = entity.sync_serialize(None, "mock-uuid")
assert serialized["roomHint"] == "Living Room"
# A child device carries no hardware/firmware fields
assert "deviceInfo" not in serialized
async def test_config_local_sdk(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
@@ -279,6 +279,7 @@
'model_id': None,
'name': 'Test Player',
'name_by_user': None,
'parent_device_id': None,
'serial_number': '**REDACTED**',
'sw_version': '1.0.0',
'via_device_id': None,
+130 -1
View File
@@ -22,7 +22,7 @@ from homeassistant.components.homekit import (
TYPE_AIR_PURIFIER,
HomeKit,
)
from homeassistant.components.homekit.accessories import HomeBridge
from homeassistant.components.homekit.accessories import HomeBridge, HomeDriver
from homeassistant.components.homekit.const import (
BRIDGE_NAME,
BRIDGE_SERIAL_NUMBER,
@@ -881,6 +881,73 @@ async def test_homekit_start_with_a_device(
await homekit.async_stop()
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_homekit_start_with_a_child_device(
hass: HomeAssistant,
hk_driver: HomeDriver,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test HomeKit start skips a child device in the configured devices list.
A child device has no connections/hardware attributes; bridge setup must
exclude it (include_child_devices=False) and warn, instead of asserting it is
a full DeviceEntry and aborting bridge creation.
"""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
device_config_entry = MockConfigEntry(domain="test", data={})
device_config_entry.add_to_hass(hass)
# A valid full device keeps the configured devices list non-empty so it does
# not fall back to matching every device in the registry.
parent = device_registry.async_get_or_create(
config_entry_id=device_config_entry.entry_id,
identifiers={("test", "parent")},
)
child = device_registry.async_get_or_create_child(
config_entry_id=device_config_entry.entry_id,
identifiers={("test", "child")},
parent_device_id=parent.id,
)
# A light entity on the child exposes device triggers; without the fix the
# child id in the devices list reached `assert isinstance(device, DeviceEntry)`.
entity_registry.async_get_or_create(
"light",
"test",
"child_light",
device_id=child.id,
)
await async_init_entry(hass, entry)
homekit = _mock_homekit(
hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[parent.id, child.id]
)
homekit.driver = hk_driver
homekit.aid_storage = MagicMock()
with (
patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception),
patch(f"{PATH_HOMEKIT}.async_show_setup_message"),
):
await homekit.async_start()
await hass.async_block_till_done()
# Setup completed (no AssertionError) and the child was skipped with a warning
# that identifies it as a child, not as missing from the device registry.
assert homekit.status == STATUS_RUNNING
assert (
f"cannot add device {child.id} because a child device cannot be a HomeKit"
" accessory" in caplog.text
)
assert "missing from the device registry" not in caplog.text
await homekit.async_stop()
async def test_homekit_stop(hass: HomeAssistant) -> None:
"""Test HomeKit stop method."""
entry = await async_init_integration(hass)
@@ -1116,6 +1183,68 @@ async def test_homekit_unpair(
homekit.status = STATUS_STOPPED
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_homekit_unpair_device_with_children(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test unpairing a device that has child devices.
Targeting a parent device expands to the parent and its children, but only
the parent carries the HomeKit pairing. The children must be skipped instead
of aborting the whole service call.
"""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_id = "light.demo"
hass.states.async_set("light.demo", "on")
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
with (
patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit),
patch("pyhap.accessory_driver.AccessoryDriver.async_start"),
):
await async_init_entry(hass, entry)
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
acc_mock.stop = AsyncMock()
aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: acc_mock}
homekit.status = STATUS_RUNNING
homekit.driver.aio_stop_event = MagicMock()
state = homekit.driver.state
state.add_paired_client(str(uuid1()).encode("utf-8"), "any", b"1")
formatted_mac = dr.format_mac(state.mac)
hk_bridge_dev = device_registry.async_get_device_by_connection(
(dr.CONNECTION_NETWORK_MAC, formatted_mac), entry.entry_id
)
child_device = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "child-outlet")},
parent_device_id=hk_bridge_dev.id,
name="Child outlet",
)
await hass.services.async_call(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
{ATTR_DEVICE_ID: hk_bridge_dev.id},
blocking=True,
)
await hass.async_block_till_done()
# The parent accessory is unpaired and the child device is skipped.
assert state.paired_clients == {}
assert isinstance(
device_registry.async_get(child_device.id), dr.ChildDeviceEntry
)
homekit.status = STATUS_STOPPED
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_homekit_unpair_missing_device_id(hass: HomeAssistant) -> None:
"""Test unpairing HomeKit accessories with invalid device id."""
+57
View File
@@ -215,6 +215,63 @@ async def test_cancel_timer(hass: HomeAssistant, init_components) -> None:
assert result.response_type is intent.IntentResponseType.ACTION_DONE
async def test_start_timer_child_device_inherits_area(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
floor_registry: fr.FloorRegistry,
) -> None:
"""Test a timer on a child device inherits the parent device's area/floor."""
entry = MockConfigEntry()
entry.add_to_hass(hass)
floor = floor_registry.async_create("first floor")
area = area_registry.async_create("kitchen")
area = area_registry.async_update(area.id, floor_id=floor.floor_id)
parent = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("test", "parent")},
)
device_registry.async_update_device(parent.id, area_id=area.id)
child = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={("test", "child")},
parent_device_id=parent.id,
)
started_event = asyncio.Event()
started_timer: TimerInfo | None = None
@callback
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
nonlocal started_timer
if event_type == TimerEventType.STARTED:
started_timer = timer
started_event.set()
async_register_timer_handler(hass, child.id, handle_timer)
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{"minutes": {"value": 5}},
device_id=child.id,
)
assert result.response_type is intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await started_event.wait()
assert started_timer is not None
# The child device has no area of its own, so it inherits the parent's.
assert started_timer.area_id == area.id
assert started_timer.area_name == "kitchen"
assert started_timer.floor_id == floor.floor_id
async def test_increase_timer(hass: HomeAssistant, init_components) -> None:
"""Test increasing the time of a running timer."""
device_id = "test_device"
@@ -54,12 +54,7 @@
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
@@ -69,14 +64,9 @@
}),
'labels': set({
}),
'manufacturer': None,
'model': None,
'model_id': None,
'name': 'Outlet 1',
'name_by_user': None,
'serial_number': None,
'sw_version': None,
'via_device_id': <ANY>,
'parent_device_id': <ANY>,
})
# ---
# name: test_state.3
@@ -164,12 +154,7 @@
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
@@ -179,14 +164,9 @@
}),
'labels': set({
}),
'manufacturer': None,
'model': None,
'model_id': None,
'name': 'Outlet 2',
'name_by_user': None,
'serial_number': None,
'sw_version': None,
'via_device_id': <ANY>,
'parent_device_id': <ANY>,
})
# ---
# name: test_state.7
@@ -9,6 +9,7 @@ from homeassistant import config_entries
from homeassistant.components.kitchen_sink 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.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -38,6 +39,24 @@ async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
assert set(states) == snapshot
@pytest.mark.usefixtures("setup_comp")
async def test_outlet_power_sensors_on_child_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the outlet power sensors are placed on child devices of the power strip."""
for entity_id in ("sensor.outlet_1_power", "sensor.outlet_2_power"):
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry is not None
child_device = device_registry.async_get(entity_entry.device_id)
assert isinstance(child_device, dr.ChildDeviceEntry)
parent_device = device_registry.async_get(child_device.parent_device_id)
assert parent_device is not None
assert not isinstance(parent_device, dr.ChildDeviceEntry)
assert parent_device.identifiers == {(DOMAIN, "2_ch_power_strip")}
@pytest.mark.usefixtures("sensor_only")
async def test_states_with_subentry(
hass: HomeAssistant, snapshot: SnapshotAssertion
+2 -1
View File
@@ -50,8 +50,9 @@ async def test_state(
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry == snapshot
sub_device_entry = device_registry.async_get(entity_entry.device_id)
assert isinstance(sub_device_entry, dr.ChildDeviceEntry)
assert sub_device_entry == snapshot
main_device_entry = device_registry.async_get(sub_device_entry.via_device_id)
main_device_entry = device_registry.async_get(sub_device_entry.parent_device_id)
assert main_device_entry == snapshot
@@ -77,6 +77,25 @@ async def test_get_triggers_non_module_device(
assert trigger[CONF_TYPE] not in not_included_types
async def test_get_triggers_child_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, entry: MockConfigEntry
) -> None:
"""Test a child device id yields no triggers instead of raising."""
await init_integration(hass, entry)
module_device = get_device(hass, entry, (0, 7, False))
# A single dash in the identifier makes the old code reach the device.model
# access, which a child device does not have.
child_device = device_registry.async_get_or_create_child(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "child-1")},
parent_device_id=module_device.id,
name="Child",
)
assert await device_trigger.async_get_triggers(hass, child_device.id) == []
async def test_if_fires_on_transponder_event(
hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry
) -> None:
+109
View File
@@ -3276,6 +3276,115 @@ async def test_area_in_device(
device_area_metric.assert_not_in_metrics(body)
@pytest.mark.parametrize("namespace", [""])
async def test_area_inherited_from_parent_device(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
client: ClientSessionGenerator,
) -> None:
"""Test an entity on a child device inherits the parent device's area."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
area = area_registry.async_create("Parent Area")
parent = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("prometheus", "parent")},
)
device_registry.async_update_device(parent.id, area_id=area.id)
child = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("prometheus", "child")},
parent_device_id=parent.id,
)
entity = entity_registry.async_get_or_create(
domain=sensor.DOMAIN,
platform="test",
unique_id="child_sensor",
unit_of_measurement=UnitOfTemperature.CELSIUS,
original_device_class=SensorDeviceClass.TEMPERATURE,
suggested_object_id="child_sensor",
original_name="Child Sensor",
device_id=child.id,
)
# The entity is seen for the first time only now, so its area is resolved via the
# child device, which has no area of its own and inherits the parent's.
set_state_with_entry(hass, entity, 21.0)
await hass.async_block_till_done()
body = await generate_latest_metrics(client)
InfoMetric(
metric_name="entity_info",
entity="sensor.child_sensor",
area="parent_area",
).assert_in_metrics(body)
@pytest.mark.parametrize("namespace", [""])
async def test_area_of_child_device_updated_when_parent_area_changes(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
client: ClientSessionGenerator,
) -> None:
"""Test a child entity's inherited area is refreshed when the parent moves."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
area_a = area_registry.async_create("Area A")
area_b = area_registry.async_create("Area B")
parent = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("prometheus", "parent")},
)
device_registry.async_update_device(parent.id, area_id=area_a.id)
child = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("prometheus", "child")},
parent_device_id=parent.id,
)
entity = entity_registry.async_get_or_create(
domain=sensor.DOMAIN,
platform="test",
unique_id="child_sensor",
unit_of_measurement=UnitOfTemperature.CELSIUS,
original_device_class=SensorDeviceClass.TEMPERATURE,
suggested_object_id="child_sensor",
original_name="Child Sensor",
device_id=child.id,
)
set_state_with_entry(hass, entity, 21.0)
await hass.async_block_till_done()
area_a_metric = InfoMetric(
metric_name="entity_info",
entity="sensor.child_sensor",
area="area_a",
)
area_b_metric = InfoMetric(
metric_name="entity_info",
entity="sensor.child_sensor",
area="area_b",
)
body = await generate_latest_metrics(client)
area_a_metric.assert_in_metrics(body)
area_b_metric.assert_not_in_metrics(body)
# Moving the parent must update the child entity's inherited area.
device_registry.async_update_device(parent.id, area_id=area_b.id)
await hass.async_block_till_done()
body = await generate_latest_metrics(client)
area_a_metric.assert_not_in_metrics(body)
area_b_metric.assert_in_metrics(body)
@pytest.mark.parametrize("namespace", [""])
async def test_area_in_entity_on_entity_id_update(
hass: HomeAssistant,
+140
View File
@@ -1219,3 +1219,143 @@ async def test_search_pre_migration_composite_device(
}
assert search(ItemType.AUTOMATION, "automation.composite") == expected_reverse
assert search(ItemType.SCRIPT, "script.composite") == expected_reverse
async def test_search_label_on_child_device(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
floor_registry: fr.FloorRegistry,
label_registry: lr.LabelRegistry,
) -> None:
"""Test searching a label that is carried by a child device.
A child device carrying a label is surfaced by a label search just like a
mains device (dr.async_entries_for_label includes child devices). Resolving
up the child yields the area it inherits from its parent (and that area's
floor), plus the child's config entry and integration. The parent device is
also returned: resolve-up follows the first-class child -> parent edge, which
here contributes the same area / config entry / integration.
"""
assert await async_setup_component(hass, DOMAIN, {})
label = label_registry.async_create("Outlet")
ground_floor = floor_registry.async_create("Ground Floor")
utility_area = area_registry.async_create("Utility", floor_id=ground_floor.floor_id)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
parent_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip")},
name="Power strip",
)
device_registry.async_update_device(parent_device.id, area_id=utility_area.id)
child_device = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip-outlet-1")},
parent_device_id=parent_device.id,
name="Outlet 1",
)
device_registry.async_update_child_device(child_device.id, labels={label.label_id})
searcher = Searcher(hass, {})
assert searcher.async_search(ItemType.LABEL, label.label_id) == {
ItemType.DEVICE: {child_device.id, parent_device.id},
ItemType.AREA: {utility_area.id},
ItemType.FLOOR: {ground_floor.floor_id},
ItemType.CONFIG_ENTRY: {config_entry.entry_id},
ItemType.INTEGRATION: {"test"},
}
async def test_search_child_devices(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
floor_registry: fr.FloorRegistry,
) -> None:
"""Test search surfaces the parent <-> child device relations.
A config entry search surfaces the entry's child devices, which
dr.async_entries_for_config_entry omits. Searching a parent device surfaces its
child devices and their entities. Searching a child device surfaces its parent
device (resolve-up), but not the parent's own entities: the parent is resolved
up, not fully searched, so unrelated sibling children are not pulled in.
"""
assert await async_setup_component(hass, DOMAIN, {})
ground_floor = floor_registry.async_create("Ground Floor")
utility_area = area_registry.async_create("Utility", floor_id=ground_floor.floor_id)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
parent_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip")},
name="Power strip",
)
device_registry.async_update_device(parent_device.id, area_id=utility_area.id)
child_device = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip-outlet-1")},
parent_device_id=parent_device.id,
name="Outlet 1",
)
parent_entity = entity_registry.async_get_or_create(
"sensor",
"test",
"strip-power",
config_entry=config_entry,
device_id=parent_device.id,
)
child_entity = entity_registry.async_get_or_create(
"switch",
"test",
"outlet-1-switch",
config_entry=config_entry,
device_id=child_device.id,
)
def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]:
"""Search."""
searcher = Searcher(hass, {})
return searcher.async_search(item_type, item_id)
# A config entry search surfaces both the mains device and its child device,
# together with the entities of each.
assert search(ItemType.CONFIG_ENTRY, config_entry.entry_id) == {
ItemType.DEVICE: {parent_device.id, child_device.id},
ItemType.ENTITY: {parent_entity.entity_id, child_entity.entity_id},
ItemType.AREA: {utility_area.id},
ItemType.FLOOR: {ground_floor.floor_id},
ItemType.INTEGRATION: {"test"},
}
# Searching the parent device surfaces its child device and the child's entity.
assert search(ItemType.DEVICE, parent_device.id) == {
ItemType.DEVICE: {child_device.id},
ItemType.ENTITY: {parent_entity.entity_id, child_entity.entity_id},
ItemType.AREA: {utility_area.id},
ItemType.FLOOR: {ground_floor.floor_id},
ItemType.CONFIG_ENTRY: {config_entry.entry_id},
ItemType.INTEGRATION: {"test"},
}
# Searching the child device surfaces its parent device, but not the parent's
# own entity: the parent is resolved up, not fully searched.
assert search(ItemType.DEVICE, child_device.id) == {
ItemType.DEVICE: {parent_device.id},
ItemType.ENTITY: {child_entity.entity_id},
ItemType.AREA: {utility_area.id},
ItemType.FLOOR: {ground_floor.floor_id},
ItemType.CONFIG_ENTRY: {config_entry.entry_id},
ItemType.INTEGRATION: {"test"},
}
+42 -1
View File
@@ -64,8 +64,9 @@ from homeassistant.components.zha.websocket_api import (
TYPE,
async_load_api,
)
from homeassistant.const import ATTR_MODEL, ATTR_NAME, Platform
from homeassistant.const import ATTR_AREA_ID, ATTR_MODEL, ATTR_NAME, Platform
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME
from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS
@@ -267,6 +268,46 @@ async def test_list_devices(zha_client) -> None:
assert device == device2
async def test_device_info_area(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> None:
"""Test the device info area_id reflects the registry device's effective area.
ZHA registers all its devices as top-level (never via ``parent_device_id``),
so a device's effective area equals its own ``area_id``.
"""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
ieee=IEEE_SWITCH_DEVICE,
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
assert zha_device_proxy.zha_device_info[ATTR_AREA_ID] is None
device_registry.async_update_device(zha_device_proxy.device_id, area_id="12345A")
assert zha_device_proxy.zha_device_info[ATTR_AREA_ID] == "12345A"
async def test_get_zha_config(zha_client) -> None:
"""Test getting ZHA custom configuration."""
await zha_client.send_json({ID: 5, TYPE: "zha/configuration"})
@@ -310,3 +310,67 @@ async def test_area_devices(
info = render_to_info(hass, f"{{{{ '{area_entry.name}' | area_devices }}}}")
assert_result_info(info, [device_entry.id])
assert info.rate_limit is None
async def test_area_functions_with_child_devices(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test area functions resolve child devices by effective area."""
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
garage = area_registry.async_get_or_create("Garage")
garden = area_registry.async_get_or_create("Garden")
parent = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip")},
name="Power strip",
)
device_registry.async_update_device(parent.id, area_id=garage.id)
inheriting_child = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip_outlet_1")},
parent_device_id=parent.id,
name="Outlet 1",
)
overriding_child = device_registry.async_get_or_create_child(
config_entry_id=config_entry.entry_id,
identifiers={("test", "strip_outlet_2")},
parent_device_id=parent.id,
name="Outlet 2",
)
device_registry.async_update_child_device(overriding_child.id, area_id=garden.id)
entity_entry = entity_registry.async_get_or_create(
"switch",
"test",
"outlet_1",
config_entry=config_entry,
device_id=inheriting_child.id,
suggested_object_id="outlet_1",
)
# area_id resolves a child device by its effective area
info = render_to_info(hass, f"{{{{ area_id('{inheriting_child.id}') }}}}")
assert_result_info(info, garage.id)
info = render_to_info(hass, f"{{{{ area_id('{overriding_child.id}') }}}}")
assert_result_info(info, garden.id)
# And for an entity on an inheriting child device
info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}")
assert_result_info(info, garage.id)
# area_name resolves a child device by its effective area
info = render_to_info(hass, f"{{{{ area_name('{inheriting_child.id}') }}}}")
assert_result_info(info, "Garage")
# area_devices includes child devices by effective area
info = render_to_info(hass, f"{{{{ area_devices('{garage.id}') }}}}")
assert_result_info(info, [parent.id, inheriting_child.id])
info = render_to_info(hass, f"{{{{ area_devices('{garden.id}') }}}}")
assert_result_info(info, [overriding_child.id])
# area_entities includes entities on child devices in the area
info = render_to_info(hass, f"{{{{ area_entities('{garage.id}') }}}}")
assert_result_info(info, [entity_entry.entity_id])

Some files were not shown because too many files have changed in this diff Show More