mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Add child devices (#178666)
Co-authored-by: Artur Pragacz <artur@pragacz.com>
This commit is contained in:
co-authored by
Artur Pragacz
parent
24a15d3ce4
commit
ad96b06be6
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user