diff --git a/.github/renovate.json b/.github/renovate.json index 08b017c21e2a95..ff8501778252c7 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,10 +7,13 @@ "pip_requirements", "pre-commit", "dockerfile", + "npm", "custom.regex", "homeassistant-manifest" ], + "ignorePaths": ["**/node_modules/**"], + "pre-commit": { "enabled": true }, @@ -180,6 +183,15 @@ "enabled": true, "labels": ["dependency"] }, + { + "description": "pnpm version pinned in the E2E tests packageManager field (allowlisted)", + "matchManagers": ["npm"], + "matchFileNames": ["tests/e2e/package.json"], + "matchDepTypes": ["packageManager"], + "matchPackageNames": ["pnpm"], + "enabled": true, + "labels": ["dependency"] + }, { "description": "For types-* stubs, only allow patch updates. Major/minor bumps track the upstream runtime package version and must be manually coordinated with the corresponding pin.", "matchPackageNames": ["/^types-/"], diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 62a236c0b0c4b4..0c9f3eac5b9802 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -5,6 +5,8 @@ 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: + return None + + area_id = dr.async_get_effective_area_id(device_registry.hass, device_entry) - if device_entry is None or device_entry.area_id is None: + if area_id is None: return None - return area_dict.get(device_entry.area_id) + return area_dict.get(area_id) def _lookup_device( diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py index 1a4eca3844b29d..29dd7f0a019165 100644 --- a/homeassistant/components/alexa_devices/services.py +++ b/homeassistant/components/alexa_devices/services.py @@ -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", diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 22057ca5f6961c..b156a540fe82b3 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -734,6 +734,35 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: 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(): diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index df503af7b46e17..d11c4a3c5be5b6 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -887,7 +887,7 @@ def _create_token_stats( cached_input_tokens = 0 if input_usage: input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + cached_input_tokens = input_usage.cache_read_input_tokens or 0 output_tokens = response_usage.output_tokens return { "stats": { diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a54bec88a861c8..0ad020df7a3a1e 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1382,7 +1382,7 @@ def _get_all_targets_in_satellite_area( 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 @@ def _get_all_targets_in_satellite_area( 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 diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 417f024b6f70be..411b125f439bb6 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -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.""" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 75cab39cbaa8fe..64322f93b7de9c 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -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) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 8f3e46c72a62dd..cf9ec3c9e52734 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -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( diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 49b4a6cf694cae..dc5360c783cba1 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -500,12 +500,25 @@ def _handle_device_registry_updated( 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 diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index fa6f8c2be7d49c..462448c5536a07 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -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.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)) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3e7a4cc8e90a8f..e40eb3afbb51f6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1254,12 +1254,10 @@ def _get_satellite_area_and_device( 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 diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index f86483c217f094..58e4b649997979 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -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 = [] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 4fc6531ed74ac3..eb33dad6cced95 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -38,7 +38,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index c813c5f2a8f9ff..739d2ab1460447 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -114,10 +114,12 @@ def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None: 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 diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index bca2cc73fd9ea5..4696ae371c4ca6 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -314,7 +314,10 @@ async def get( 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 ) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index b02d00a45e2e82..f890b6c14a056d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -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 diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index d18182a346a36c..26a11f7d6b4eb4 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -41,7 +41,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 2ec4c42cc9e246..4f50a4d7df3f23 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -51,7 +51,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b8cab9cead119f..57de7e161e7190 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -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 @@ def sync_serialize(self, agent_user_id, instance_uuid): 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 diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py index c86e3006ff2f08..17e73f56f25b2d 100644 --- a/homeassistant/components/hassio/services.py +++ b/homeassistant/components/hassio/services.py @@ -455,7 +455,11 @@ async def async_mount_reload(service: ServiceCall) -> None: """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", diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 4b862d21f597f0..973a05319a5a95 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -27,7 +27,7 @@ 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 @@ def __init__( 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) diff --git a/homeassistant/components/home_connect/climate.py b/homeassistant/components/home_connect/climate.py index 68451c1cda5a9e..7b89d331e39562 100644 --- a/homeassistant/components/home_connect/climate.py +++ b/homeassistant/components/home_connect/climate.py @@ -5,7 +5,7 @@ from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import Execution +from aiohomeconnect.model.program import Execution, ProgramDefinitionConstraints from homeassistant.components.climate import ( FAN_AUTO, @@ -14,13 +14,13 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN +from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN, UNIT_MAP from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -111,10 +111,6 @@ async def async_setup_entry( class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): """Representation of a Home Connect climate entity.""" - # Note: The base class requires this to be set even though this - # class doesn't support any temperature related functionality. - _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__( self, coordinator: HomeConnectApplianceCoordinator, @@ -157,6 +153,70 @@ def preset_modes(self) -> list[str] | None: else None ) + @property + @override + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + if event := self.appliance.events.get( + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_SETPOINT_TEMPERATURE + ): + return cast(float, event.value) + return None + + @property + @override + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if ( + ( + option_definition := self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE + ) + ) + and (proto_unit := option_definition.unit) is not None + and (unit := UNIT_MAP.get(proto_unit)) is not None + ): + return unit + return UnitOfTemperature.CELSIUS + + def _get_temperature_constraints(self) -> ProgramDefinitionConstraints | None: + """Get the temperature constraints for the appliance.""" + if option_definition := self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE + ): + return option_definition.constraints + return None + + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature.""" + if ( + option_constraints := self._get_temperature_constraints() + ) and option_constraints.min is not None: + return option_constraints.min + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature.""" + if ( + option_constraints := self._get_temperature_constraints() + ) and option_constraints.max is not None: + return option_constraints.max + return super().max_temp + + @property + @override + def target_temperature_step(self) -> float | None: + """Return the temperature step.""" + if ( + option_constraints := self._get_temperature_constraints() + ) and option_constraints.step_size is not None: + return option_constraints.step_size + return None + @property @override def supported_features(self) -> ClimateEntityFeature: @@ -166,6 +226,10 @@ def supported_features(self) -> ClimateEntityFeature: features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE + if self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE + ): + features |= ClimateEntityFeature.TARGET_TEMPERATURE if self.appliance.options.get( OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE ): @@ -202,6 +266,12 @@ async def async_added_to_hass(self) -> None: EventKey.BSH_COMMON_SETTING_POWER_STATE, ) ) + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_SETPOINT_TEMPERATURE, + ) + ) @override def update_native_value(self) -> None: @@ -332,10 +402,19 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode]) + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: + await self.async_set_option_with_key( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + temp, + ) + @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await super().async_set_option_with_key( + await self.async_set_option_with_key( OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, FAN_MODES_OPTIONS[fan_mode], ) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index eb0b0f2830f914..22d0c508bc3015 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -108,6 +108,12 @@ key=OptionKey.BSH_COMMON_START_IN_RELATIVE, translation_key="start_in_relative", ), + NumberEntityDescription( + key=OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + translation_key="setpoint_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + native_step=1, + ), NumberEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY, translation_key="fill_quantity", diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index 7425343705d74e..c735e39371ecc7 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -1,6 +1,8 @@ """Custom actions (previously known as services) for the Home Connect integration.""" -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable +from functools import partial +import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient @@ -12,12 +14,14 @@ SettingKey, ) from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError +from aiohomeconnect.model.program import Program, ProgramDefinition import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.const import ATTR_DEVICE_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( AFFECTS_TO_ACTIVE_PROGRAM, @@ -36,6 +40,8 @@ from .coordinator import HomeConnectConfigEntry from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -64,6 +70,9 @@ ( OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE ): vol.All(int, vol.Range(min=1, max=100)), + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE: vol.Coerce( + float + ), OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)), OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool, @@ -91,6 +100,11 @@ } ) +TEMPERATURE_OPTIONS = { + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, +} + def _require_program_or_at_least_one_option(data: dict) -> dict: if ATTR_PROGRAM not in data and not any( @@ -218,6 +232,41 @@ async def async_service_setting(call: ServiceCall) -> None: ) from err +async def _check_temperature_options( + options: list[Option], + method_call: Callable[..., Awaitable[Program | ProgramDefinition]], +) -> None: + if not options or not ( + options_to_check := { + option.key: option + for option in options + if option.key in TEMPERATURE_OPTIONS + } + ): + return + + try: + program_data = await method_call() + except HomeConnectError: + LOGGER.debug("Failed to get information about temperature options, using °C") + else: + checked_options = [] + for option in program_data.options or []: + if _option := options_to_check.get(option.key): + checked_options.append(option.key) + if option.unit == "°F": + _option.value = TemperatureConverter.convert( + _option.value, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + if set(checked_options) != options_to_check.keys(): + LOGGER.debug( + "Couldn't check all the temperature options units," + " using °C for the ones that couldn't be checked" + ) + + async def async_service_set_program_and_options(call: ServiceCall) -> None: """Service for setting a program and options.""" data = dict(call.data) @@ -247,6 +296,10 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None: if isinstance(program, ProgramKey) else TRANSLATION_KEYS_PROGRAMS_MAP[program] ) + await _check_temperature_options( + options, + partial(client.get_available_program, ha_id, program_key=program), + ) if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: method_call = client.start_program( @@ -261,12 +314,18 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None: else: array_of_options = ArrayOfOptions(options) if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + await _check_temperature_options( + options, partial(client.get_active_program, ha_id) + ) method_call = client.set_active_program_options( ha_id, array_of_options=array_of_options ) exception_translation_key = "set_options_active_program" else: # affects_to is AFFECTS_TO_SELECTED_PROGRAM + await _check_temperature_options( + options, partial(client.get_selected_program, ha_id) + ) method_call = client.set_selected_program_options( ha_id, array_of_options=array_of_options ) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index f1cd13d68e124a..a81a873ded2a46 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -284,6 +284,14 @@ set_program_and_options: air_conditioner_options: collapsed: true fields: + heating_ventilation_air_conditioning_air_conditioner_option_setpoint_temperature: + example: 22 + required: false + selector: + number: + step: 0.1 + mode: box + unit_of_measurement: °C heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage: example: 50 required: false @@ -590,7 +598,7 @@ set_program_and_options: min: 0 step: 1 mode: box - unit_of_measurement: °C/°F + unit_of_measurement: °C b_s_h_common_option_duration: example: 900 required: false diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1f04588dea7d6a..1c07a1c2bedc25 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -2316,6 +2316,10 @@ "description": "Setting to adjust the venting level of the air conditioner as a percentage.", "name": "Fan speed percentage" }, + "heating_ventilation_air_conditioning_air_conditioner_option_setpoint_temperature": { + "description": "Defines the target temperature, which will be held by the air conditioner.", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]" + }, "laundry_care_common_option_silent_mode": { "description": "Defines if the silent mode is activated.", "name": "Silent mode" diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index 11c3c9e30caaa3..c851859abf10ba 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -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)) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 55f2ae2d3bc87a..017e9c721fca75 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -494,6 +494,9 @@ async def async_handle_homekit_unpair(service: ServiceCall) -> 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 @@ async def _async_add_trigger_accessories(self) -> None: 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 @@ async def _async_add_trigger_accessories(self) -> None: 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 @@ async def _async_add_trigger_accessories(self) -> None: 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 @@ async def _async_set_device_info_attributes( """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: diff --git a/homeassistant/components/homekit_controller/diagnostics.py b/homeassistant/components/homekit_controller/diagnostics.py index f72186439dc9bf..a90eb9ea25f4cb 100644 --- a/homeassistant/components/homekit_controller/diagnostics.py +++ b/homeassistant/components/homekit_controller/diagnostics.py @@ -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)) diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 8244db1b067f67..100467d77256a1 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -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 diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 8621d59faa644c..b445f8be9abd3f 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -38,7 +38,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/intelliclima/entity.py b/homeassistant/components/intelliclima/entity.py index 2eafb29e282e14..d48fb44dc7454c 100644 --- a/homeassistant/components/intelliclima/entity.py +++ b/homeassistant/components/intelliclima/entity.py @@ -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 @@ def __init__( """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] = { diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py index 082987914327f4..fa5c27e22bb5ff 100644 --- a/homeassistant/components/intent/llm.py +++ b/homeassistant/components/intent/llm.py @@ -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) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 1538869f86c195..a54137142e898c 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -294,11 +294,10 @@ def start_timer( # 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 diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index cac53a519c0092..edd5845b6e5786 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -53,7 +53,10 @@ IZONE_SERVICE_AIRFLOW_SCHEMA: VolDictType = { vol.Required(ATTR_AIRFLOW): vol.All( - vol.Coerce(int), vol.Range(min=0, max=100), msg="invalid airflow" + vol.Coerce(float), + vol.In(range(0, 101, 5)), + vol.Coerce(int), + msg="invalid airflow", ), } diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index c3d285312dec98..2cad5c90de1517 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -9,7 +9,7 @@ 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 @@ def __init__( 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 @@ def __init__( 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, + ) diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py index e555adf0752086..f09e925a8bc118 100644 --- a/homeassistant/components/kitchen_sink/switch.py +++ b/homeassistant/components/kitchen_sink/switch.py @@ -6,7 +6,7 @@ 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 @@ def __init__( 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 @@ def __init__( 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 diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index 799103e71c0605..c7536555432e3e 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -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)) diff --git a/homeassistant/components/lg_netcast/helpers.py b/homeassistant/components/lg_netcast/helpers.py index 7cfc0d502716b0..8014094510c6cb 100644 --- a/homeassistant/components/lg_netcast/helpers.py +++ b/homeassistant/components/lg_netcast/helpers.py @@ -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 diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 91a680e46c5706..bf0a9cee5f5c9f 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -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) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 4b1b946be146ce..ed383a44927d27 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -34,7 +34,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index b2995a0ae648f8..e494e7df45a0eb 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -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, diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index c5f3e39d6d553b..7f23142a935e95 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -131,7 +131,9 @@ def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry: 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 diff --git a/homeassistant/components/nanoleaf/device_trigger.py b/homeassistant/components/nanoleaf/device_trigger.py index 387f7c276ed2ab..df4a1d6d9eb9df 100644 --- a/homeassistant/components/nanoleaf/device_trigger.py +++ b/homeassistant/components/nanoleaf/device_trigger.py @@ -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: diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index c71bba59b5c883..9e2b8d2ec94f6b 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -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 diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py index 068419f68b52c9..78762b6124a869 100644 --- a/homeassistant/components/nexia/entity.py +++ b/homeassistant/components/nexia/entity.py @@ -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.""" diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py index 73cbaaabe7ba40..a6db842dc74721 100644 --- a/homeassistant/components/ntfy/entity.py +++ b/homeassistant/components/ntfy/entity.py @@ -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, diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index 9e627c0e002d51..5a24ac9fd9db34 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -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", diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py index 02adcfb78b4b67..f04d23bd2ec571 100644 --- a/homeassistant/components/opendisplay/services.py +++ b/homeassistant/components/opendisplay/services.py @@ -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", diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 61d1d84a3b4b31..43339c61083bae 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -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 diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 352d32dc8bd9b0..d02bd2f2659ecb 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -98,6 +98,9 @@ def async_create( notifications = _async_get_or_create_notifications(hass) if notification_id is None: notification_id = random_uuid_hex() + update_type = ( + UpdateType.UPDATED if notification_id in notifications else UpdateType.ADDED + ) notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, @@ -108,7 +111,7 @@ def async_create( async_dispatcher_send( hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, - UpdateType.ADDED, + update_type, {notification_id: notifications[notification_id]}, ) diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index 68115ca6fd1d50..6a2530c6c9d4cb 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -54,7 +54,7 @@ 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", diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 681f7a950f70e6..d2d9b6af670d40 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -369,11 +369,23 @@ def handle_device_registry_updated( 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 @@ def _find_area_id(self, entity_id: str) -> str | None: 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 diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index 5867fb41829ad3..81bebf40d6e458 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -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( diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index 6a16ef5a0e3753..f6449635e433e0 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -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 diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index b9ab22a74b0bb7..36bd2e16119d52 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -283,6 +283,13 @@ def _async_search_config_entry(self, config_entry_id: str) -> None: 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 @@ def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> N # 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 @@ def _async_add_automations_and_scripts_for_device( 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 @@ def _async_search_script_blueprint(self, blueprint_path: str) -> None: ) @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 @@ def _async_resolve_up_entity(self, entity_id: str) -> er.RegistryEntry | None: 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) diff --git a/homeassistant/components/simplisafe/services.py b/homeassistant/components/simplisafe/services.py index 55c465ec1840e6..57593063ab29b2 100644 --- a/homeassistant/components/simplisafe/services.py +++ b/homeassistant/components/simplisafe/services.py @@ -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, diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index d4e77a3a268745..4e3a7245b689d4 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -44,7 +44,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 9ee64a32291f99..2f96a2eae69be3 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -123,7 +123,6 @@ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: 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.+)-{old_unique_id}$", identifier[1] diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py index 1b71426a89fe0b..87f6325f6ad18a 100644 --- a/homeassistant/components/telegram_bot/entity.py +++ b/homeassistant/components/telegram_bot/entity.py @@ -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, diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index dd1415d29a9bc6..e787ac7238ca9b 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.8.2"] + "requirements": ["tesla-fleet-api==1.9.0"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 6de94c7b23b246..07ceef090796d1 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.8.2", "teslemetry-stream==0.10.0"] + "requirements": ["tesla-fleet-api==1.9.0", "teslemetry-stream==0.10.0"] } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index c1b1c793c72742..7562c972ea0e76 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -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", diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 8beefc516027f9..09eb2891de74e5 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.8.2"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.9.0"] } diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 125443a06cb210..6afb5aea8a3659 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -30,7 +30,7 @@ ) 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 @@ def __init__( 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 diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index a6340ca89c2ce1..24ec146966f3e1 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,7 +32,7 @@ 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 @@ def __init__( 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 diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 4483e449ca1206..798c5debab6ee9 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -326,6 +326,10 @@ async def _async_convert_audio( if from_extension: command.extend(["-f", from_extension]) + if is_input_gen and from_extension == "wav": + # The container is known, so minimize probing latency for live TTS audio. + command.extend(["-probesize", "32"]) + if is_input_gen: # Async generator command.extend(["-i", "pipe:0"]) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 6b5b76318986e8..0b4facb368cb04 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "silver", - "requirements": ["aiounifi==91"] + "requirements": ["aiounifi==92"] } diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 3dbffa8bbe9e76..0eed98805fd0c4 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -54,7 +54,9 @@ async def async_call_unifi_service(service_call: ServiceCall) -> 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( diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index bb89766c771947..6b9099546c5c0a 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -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) diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 6053c8c4620d0d..7bdd2f2103f233 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -159,7 +159,9 @@ def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: 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 diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 2b3d1d9ea7b589..636a93194bf38d 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -129,7 +129,7 @@ async def device_discovered( 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 device_discovered( "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), diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 7a1f1ddef4c7cc..8ff9d420a9afe0 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,7 +8,7 @@ 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 @@ def __init__( *, 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 diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index a952c96b9eaae0..32b0f058b0d11c 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -12,8 +12,10 @@ AppRecord, InputInfo, SettingInfo, + StateExtended, Vizio, VizioError, + VizioNotFoundError, fetch_app_availability, fetch_remote_app_catalog, is_app_input, @@ -126,6 +128,10 @@ def __init__( update_interval=SCAN_INTERVAL, ) self.device = device + # Modern firmware bundles power/input/app state into one endpoint; + # firmware without it never gains it, so probe only until the first + # URI_NOT_FOUND response. + self._use_state_extended = True @override async def _async_setup(self) -> None: @@ -146,19 +152,35 @@ async def _async_setup(self) -> None: sw_version=version, ) + def _update_failed(self) -> UpdateFailed: + """Return the translated failure raised when the device is unreachable.""" + return UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={ + "host": self.config_entry.data[CONF_HOST], + }, + ) + @override async def _async_update_data(self) -> VizioDeviceData: """Fetch all device data.""" - try: - is_on = await self.device.get_power_state() - except VizioError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={ - "host": self.config_entry.data[CONF_HOST], - }, - ) from err + state: StateExtended | None = None + if self._use_state_extended: + try: + state = await self.device.get_state_extended() + except VizioNotFoundError: + self._use_state_extended = False + except VizioError as err: + raise self._update_failed() from err + + if state is not None: + is_on = state.power_on + else: + try: + is_on = await self.device.get_power_state() + except VizioError as err: + raise self._update_failed() from err if not is_on: return VizioDeviceData(is_on=False) @@ -174,17 +196,26 @@ async def _async_update_data(self) -> VizioDeviceData: if sound_mode: sound_mode_list = list(sound_mode.options) - current_input = await _optional(self.device.get_current_input()) + current_input: str | None + if state is not None: + current_input = state.current_input + else: + current_input = await _optional(self.device.get_current_input()) input_list = await _optional(self.device.get_inputs()) current_app_config = None - # Only attempt to fetch app config if the device is a TV and supports apps + # Only report app config if the device is a TV and supports apps if ( self.config_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV and input_list and any(is_app_input(input_item.name) for input_item in input_list) ): - current_app_config = await _optional(self.device.get_current_app_config()) + if state is not None: + current_app_config = state.current_app + else: + current_app_config = await _optional( + self.device.get_current_app_config() + ) return VizioDeviceData( is_on=True, diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 4387bcfd50d42c..fde9ade30e4a71 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -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 diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index ccc2de63ab64eb..681620f6578329 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -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 diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py index cea946325fbfd5..41a8c68e3cb85c 100644 --- a/homeassistant/components/yolink/device_trigger.py +++ b/homeassistant/components/yolink/device_trigger.py @@ -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, diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 2c21f66ecca930..1ede5391c30e1d 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -444,7 +444,9 @@ def zha_device_info(self) -> dict[str, Any]: 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 @@ async def _handle_entity_registry_updated( 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 diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index f0b5db4fe9a0c2..0dbbf7bb7a2254 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -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)): diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index e129eb7123e296..e38b6f7991752d 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -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: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index cf96eee725da39..82b36a3fa38b78 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,7 +12,17 @@ import os import shutil import time -from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, Unpack, override +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NamedTuple, + Required, + TypedDict, + Unpack, + overload, + override, +) import attr from yarl import URL @@ -64,7 +74,7 @@ ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 3 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 CLEANUP_DELAY = 10 @@ -109,6 +119,8 @@ class DeviceEntryDisabler(StrEnum): """What disabled a device entry.""" CONFIG_ENTRY = "config_entry" + # A child device disabled because its parent device is disabled. + DEVICE = "device" INTEGRATION = "integration" USER = "user" @@ -138,6 +150,24 @@ class DeviceInfo(TypedDict, total=False): via_device_id: str +class ChildDeviceInfo(TypedDict, total=False): + """Entity device information for a child device in the device registry. + + A child device is a lightweight logical part of a parent device. The parent + is referenced by its device id, must already be registered by the same config + entry, and must belong to the same config subentry. + """ + + created_at: str + identifiers: Required[set[tuple[str, str]]] + modified_at: str + name: str | None + parent_device_id: Required[str] + suggested_area: str | None + translation_key: str | None + translation_placeholders: Mapping[str, str] | None + + DEVICE_INFO_TYPES = { # Device info is categorized by finding the first device info type which has all # the keys of the device info. The link device info type must be kept first @@ -230,7 +260,7 @@ class DeviceIdentifierCollisionError(DeviceCollisionError): """Raised when a device identifier collision is detected.""" def __init__( - self, identifiers: set[tuple[str, str]], existing_device: DeviceEntry + self, identifiers: set[tuple[str, str]], existing_device: AnyDeviceEntry ) -> None: """Initialize error.""" super().__init__( @@ -392,24 +422,87 @@ def _normalize_connections_validator( @attr.s(frozen=True, slots=True) -class DeviceEntry: - """Device Registry Entry.""" +class BaseDeviceEntry: + """Base class for device registry entries.""" config_entry_id: str = attr.ib() area_id: str | None = attr.ib(default=None) config_subentry_id: str | None = attr.ib(default=None) + created_at: datetime = attr.ib(factory=utcnow) + disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) + id: str = attr.ib(factory=uuid_util.random_uuid_hex) + identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) + labels: set[str] = attr.ib(converter=set, factory=set) + modified_at: datetime = attr.ib(factory=utcnow) + name_by_user: str | None = attr.ib(default=None) + name: str | None = attr.ib(default=None) + _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id. + """ + return {self.config_entry_id} + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry and subentry, available as config_entry_id and config_subentry_id. + """ + return {self.config_entry_id: {self.config_subentry_id}} + + @property + def primary_config_entry(self) -> str: + """Return the primary config entry of this device. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id, which is its primary config entry. + """ + return self.config_entry_id + + @property + def disabled(self) -> bool: + """Return if entry is disabled.""" + return self.disabled_by is not None + + @property + def dict_repr(self) -> dict[str, Any]: + """Return a dict representation of the entry.""" + raise NotImplementedError + + @under_cached_property + def json_repr(self) -> bytes | None: + """Return a cached JSON representation of the entry.""" + try: + dict_repr = self.dict_repr + return json_bytes(dict_repr) + except ValueError, TypeError: + _LOGGER.error( + "Unable to serialize entry %s to JSON. Bad data found at %s", + self.id, + format_unserializable_data( + find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) + ), + ) + return None + + +@attr.s(frozen=True, slots=True) +class DeviceEntry(BaseDeviceEntry): + """Device Registry Entry.""" + configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib( converter=set, factory=set, validator=_normalize_connections_validator ) - created_at: datetime = attr.ib(factory=utcnow) - disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) entry_type: DeviceEntryType | None = attr.ib(default=None) hw_version: str | None = attr.ib(default=None) - id: str = attr.ib(factory=uuid_util.random_uuid_hex) - identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) - labels: set[str] = attr.ib(converter=set, factory=set) # composite_device_id is the id of the pre-migration composite device this device was # split from; composite_primary_config_entry is that composite's former # primary_config_entry, so a restored composite device can report it. @@ -420,9 +513,6 @@ class DeviceEntry: manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) model_id: str | None = attr.ib(default=None) - modified_at: datetime = attr.ib(factory=utcnow) - name_by_user: str | None = attr.ib(default=None) - name: str | None = attr.ib(default=None) # Set on devices created by splitting a pre-migration composite device: the # identifiers and connections copied from the composite have not yet been reconciled. # On the owning integration's first re-registration they are replaced with the ones @@ -448,9 +538,9 @@ class DeviceEntry: _composite_subentries: dict[str, set[str | None]] | None = attr.ib( default=None, eq=False ) - _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @property + @override def config_entries(self) -> set[str]: """Return the config entries this device belongs to. @@ -462,6 +552,7 @@ def config_entries(self) -> set[str]: return {self.config_entry_id} @property + @override def config_entries_subentries(self) -> dict[str, set[str | None]]: """Return the config subentries this device belongs to. @@ -476,25 +567,7 @@ def config_entries_subentries(self) -> dict[str, set[str | None]]: return {self.config_entry_id: {self.config_subentry_id}} @property - def primary_config_entry(self) -> str: - """Return the primary config entry of this device. - - Deprecated compatibility shim: a device now belongs to a single config - entry, available as config_entry_id, which is its primary config entry. - - For a restored composite device (synthesized on the fly by async_get for a - pre-migration composite device id), this returns the composite's former - primary_config_entry, which is recorded on the split devices during migration as - composite_primary_config_entry. - """ - return self.config_entry_id - - @property - def disabled(self) -> bool: - """Return if entry is disabled.""" - return self.disabled_by is not None - - @property + @override def dict_repr(self) -> dict[str, Any]: """Return a dict representation of the entry.""" # Convert sets and tuples to lists @@ -527,28 +600,13 @@ def dict_repr(self) -> dict[str, Any]: "modified_at": self.modified_at.timestamp(), "name_by_user": self.name_by_user, "name": self.name, + "parent_device_id": None, "primary_config_entry": self.primary_config_entry, "serial_number": self.serial_number, "sw_version": self.sw_version, "via_device_id": self.via_device_id, } - @under_cached_property - def json_repr(self) -> bytes | None: - """Return a cached JSON representation of the entry.""" - try: - dict_repr = self.dict_repr - return json_bytes(dict_repr) - except ValueError, TypeError: - _LOGGER.error( - "Unable to serialize entry %s to JSON. Bad data found at %s", - self.id, - format_unserializable_data( - find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) - ), - ) - return None - @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" @@ -596,6 +654,107 @@ def suggested_area(self) -> str | None: return self._suggested_area +_CHILD_DEVICE_COMPAT_ATTRS = frozenset( + { + "configuration_url", + "connections", + "entry_type", + "hw_version", + "manufacturer", + "model", + "model_id", + "serial_number", + # "suggested_area", # Excluded, to be removed in 2026.9 + "sw_version", + "via_device_id", + } +) + + +@attr.s(frozen=True, slots=True) +class ChildDeviceEntry(BaseDeviceEntry): + """Child Device Registry Entry.""" + + parent_device_id: str = attr.ib(kw_only=True) + + if not TYPE_CHECKING: + # Hidden from the type checker, otherwise mypy disables [attr-defined] + # errors when it sees the __getattr__ below. + def __getattr__(self, name: str) -> Any: + """Return the DeviceEntry default for a DeviceEntry-only attribute. + + Backwards-compatibility shim for custom integrations that access an + attribute which only exists on DeviceEntry (e.g. connections, + manufacturer): they get the DeviceEntry default and a deprecation warning. + """ + if name not in _CHILD_DEVICE_COMPAT_ATTRS: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + integration_frame = None + if integration_frame is None or not integration_frame.custom_integration: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + report_usage( + f"accesses ChildDeviceEntry.{name}, which does not exist on child " + "devices", + breaks_in_ha_version="2027.9.0", + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, + ) + return set() if name == "connections" else None + + @property + @override + def dict_repr(self) -> dict[str, Any]: + """Return a dict representation of the entry.""" + # Convert sets to lists so the JSON serializer does not have to each time. + return { + "area_id": self.area_id, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "created_at": self.created_at.timestamp(), + "disabled_by": self.disabled_by, + "id": self.id, + "identifiers": list(self.identifiers), + "labels": list(self.labels), + "modified_at": self.modified_at.timestamp(), + "name_by_user": self.name_by_user, + "name": self.name, + "parent_device_id": self.parent_device_id, + } + + @under_cached_property + def as_storage_fragment(self) -> json_fragment: + """Return a json fragment for storage.""" + return json_fragment( + json_bytes( + { + "area_id": self.area_id, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "created_at": self.created_at, + "disabled_by": self.disabled_by, + "id": self.id, + "identifiers": list(self.identifiers), + "labels": list(self.labels), + "modified_at": self.modified_at, + "name_by_user": self.name_by_user, + "name": self.name, + "parent_device_id": self.parent_device_id, + } + ) + ) + + +type AnyDeviceEntry = DeviceEntry | ChildDeviceEntry + + # async_update_device arguments that redefine which identifiers/connections a device is # keyed by, or move it to another config entry. They are ambiguous on a synthesized # composite (there is no single underlying device to retarget), so the composite shim @@ -657,6 +816,26 @@ def config_entries_subentries(self) -> dict[str, set[str | None]]: return {} return {self.config_entry_id: {self.config_subentry_id}} + def _calculate_disable_by( + self, + config_entry: ConfigEntry, + disabled_by: DeviceEntryDisabler | UndefinedType | None, + ) -> DeviceEntryDisabler | None: + """Calculate disabled_by when restoring a deleted device.""" + if self.disabled_by is UNDEFINED: + return disabled_by if disabled_by is not UNDEFINED else None + disabled_by = self.disabled_by + if disabled_by == DeviceEntryDisabler.DEVICE: + # self.disabled_by is DEVICE only for a former child device, clear it + # (to_child_device_entry re-derives it from the new parent device). + disabled_by = None + if config_entry.disabled_by: + if disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + return disabled_by + def to_device_entry( self, config_entry: ConfigEntry, @@ -666,16 +845,7 @@ def to_device_entry( disabled_by: DeviceEntryDisabler | UndefinedType | None, ) -> DeviceEntry: """Create DeviceEntry from DeletedDeviceEntry.""" - # Adjust disabled_by based on config entry state - if self.disabled_by is not UNDEFINED: - disabled_by = self.disabled_by - if config_entry.disabled_by: - if disabled_by is None: - disabled_by = DeviceEntryDisabler.CONFIG_ENTRY - elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: - disabled_by = None - else: - disabled_by = disabled_by if disabled_by is not UNDEFINED else None + disabled_by = self._calculate_disable_by(config_entry, disabled_by) return DeviceEntry( area_id=self.area_id, config_entry_id=config_entry.entry_id, @@ -690,6 +860,38 @@ def to_device_entry( name_by_user=self.name_by_user, ) + def to_child_device_entry( + self, + config_entry: ConfigEntry, + config_subentry_id: str | None, + identifiers: set[tuple[str, str]], + disabled_by: DeviceEntryDisabler | UndefinedType | None, + parent_device: DeviceEntry, + ) -> ChildDeviceEntry: + """Create ChildDeviceEntry from DeletedDeviceEntry.""" + disabled_by = self._calculate_disable_by(config_entry, disabled_by) + # Re-derive parent-device disable from the (possibly different) + # parent device. + if ( + self.disabled_by is not UNDEFINED + and disabled_by is None + and parent_device.disabled + ): + disabled_by = DeviceEntryDisabler.DEVICE + return ChildDeviceEntry( + area_id=self.area_id, + config_entry_id=config_entry.entry_id, + config_subentry_id=config_subentry_id, + created_at=self.created_at, + disabled_by=disabled_by, + # type ignores: likely https://github.com/python/mypy/issues/8625 + identifiers=identifiers, # type: ignore[arg-type] + id=self.id, + labels=self.labels, # type: ignore[arg-type] + name_by_user=self.name_by_user, + parent_device_id=parent_device.id, + ) + @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" @@ -1011,6 +1213,10 @@ def _split_for_via_device( if device["via_device_id"] == device["id"]: device["via_device_id"] = None + if old_major_version < 3 or (old_major_version == 3 and old_minor_version < 4): + # Version 3.4 adds child devices, introduced in 2026.9 + old_data.setdefault("child_devices", []) + if old_major_version > 3: raise NotImplementedError return old_data @@ -1050,7 +1256,8 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( Registry bugs used to allow duplicate keys within a config entry, so old stores can hold them. Only the last indexed device occupies the slot (matching historic - lookup behavior); the others are recorded as shadowed until reconciled: + lookup behavior); the others are recorded as shadowed until collisions are + reconciled: - (config_entry_id, (connection_type, connection identifier)) -> {device_id} - (config_entry_id, (DOMAIN, identifier)) -> {device_id} """ @@ -1366,57 +1573,165 @@ def get_composite_splits(self) -> dict[str, list[DeviceEntry]]: } -class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): - """Container for deleted device registry entries. +class ChildDeviceRegistryItems(BaseRegistryItems[ChildDeviceEntry]): + """Container for child device registry entries, maps child device id -> entry. - A deleted device that still belongs to a config entry is indexed by config entry id in - the base class, like an active device. An orphaned deleted device (its config entry - removed) has no config entry id and would collide with every other orphan in the base - config_entry_id=None slot, so orphans are kept out of the base index and tracked in a - separate index keyed by device id, which is unique so orphans never shadow each other. - Orphans are matched on restore by get_orphaned_entry. + Maintains five additional indexes. Identifiers are unique per config entry, + shared with the parent devices' identifier namespace: + - (DOMAIN, identifier) -> {config_entry_id: entry} + - parent_device_id -> dict[key, True] + - config_entry_id -> dict[key, True] + - area_id -> dict[key, True] (explicitly set areas only, not inherited ones) + - label -> dict[key, True] """ def __init__(self) -> None: """Initialize the container.""" super().__init__() - self._orphaned_connections: dict[ - tuple[str, str], dict[str, DeletedDeviceEntry] - ] = {} - self._orphaned_identifiers: dict[ - tuple[str, str], dict[str, DeletedDeviceEntry] - ] = {} + self._identifiers: dict[tuple[str, str], dict[str, ChildDeviceEntry]] = {} + self._parent_device_id_index: RegistryIndexType = defaultdict(dict) + self._config_entry_id_index: RegistryIndexType = defaultdict(dict) + self._area_id_index: RegistryIndexType = defaultdict(dict) + self._labels_index: RegistryIndexType = defaultdict(dict) @override - def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None: - """Index an entry, keeping orphans in the separate id-keyed index.""" - if entry.config_entry_id is not None: - super()._index_entry(key, entry) - return - for connection in entry.connections: - self._orphaned_connections.setdefault(connection, {})[entry.id] = entry + def _index_entry(self, key: str, entry: ChildDeviceEntry) -> None: + """Index an entry.""" + # Unlike DeviceRegistryItems, this identifier index has no shadow tracking, so + # two same-entry children sharing an identifier let the last indexed own the + # slot. Normal operation can't reach this: _validate_child_identifiers rejects a + # same-entry identifier collision before insert. It's only possible via a + # hand-edited or corrupt store, and is acceptable (the slot stays consistent + # because _unindex_entry only clears the slot it still holds). for identifier in entry.identifiers: - self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry + self._identifiers.setdefault(identifier, {})[entry.config_entry_id] = entry + self._parent_device_id_index[entry.parent_device_id][key] = True + self._config_entry_id_index[entry.config_entry_id][key] = True + if (area_id := entry.area_id) is not None: + self._area_id_index[area_id][key] = True + for label in entry.labels: + self._labels_index[label][key] = True @override def _unindex_entry( - self, key: str, replacement_entry: DeletedDeviceEntry | None = None + self, key: str, replacement_entry: ChildDeviceEntry | None = None ) -> None: - """Unindex an entry from the base or the orphan index.""" + """Unindex an entry.""" entry = self.data[key] - if entry.config_entry_id is not None: - super()._unindex_entry(key, replacement_entry) - return - for connection in entry.connections: - if connection in self._orphaned_connections: - del self._orphaned_connections[connection][entry.id] - if not self._orphaned_connections[connection]: - del self._orphaned_connections[connection] for identifier in entry.identifiers: - if identifier in self._orphaned_identifiers: - del self._orphaned_identifiers[identifier][entry.id] - if not self._orphaned_identifiers[identifier]: - del self._orphaned_identifiers[identifier] + by_config_entry = self._identifiers.get(identifier) + if ( + by_config_entry is not None + and by_config_entry.get(entry.config_entry_id) is entry + ): + del by_config_entry[entry.config_entry_id] + if not by_config_entry: + del self._identifiers[identifier] + self._unindex_entry_value( + key, entry.parent_device_id, self._parent_device_id_index + ) + self._unindex_entry_value( + key, entry.config_entry_id, self._config_entry_id_index + ) + if (area_id := entry.area_id) is not None: + self._unindex_entry_value(key, area_id, self._area_id_index) + for label in entry.labels: + self._unindex_entry_value(key, label, self._labels_index) + + def get_entry( + self, + identifiers: set[tuple[str, str]], + *, + config_entry_id: str, + ) -> ChildDeviceEntry | None: + """Get the first child device matching an identifier within the config entry.""" + for identifier in identifiers: + if ( + by_config_entry := self._identifiers.get(identifier) + ) is not None and config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] + return None + + def get_children_for_device_id( + self, parent_device_id: str + ) -> list[ChildDeviceEntry]: + """Get the child devices of a parent device.""" + data = self.data + return [ + data[key] for key in self._parent_device_id_index.get(parent_device_id, ()) + ] + + def get_devices_for_config_entry_id( + self, config_entry_id: str + ) -> list[ChildDeviceEntry]: + """Get child devices for config entry.""" + data = self.data + return [ + data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) + ] + + def get_devices_for_area_id(self, area_id: str) -> list[ChildDeviceEntry]: + """Get child devices with an explicitly set area.""" + data = self.data + return [data[key] for key in self._area_id_index.get(area_id, ())] + + def get_devices_for_label(self, label: str) -> list[ChildDeviceEntry]: + """Get child devices for label.""" + data = self.data + return [data[key] for key in self._labels_index.get(label, ())] + + +class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): + """Container for deleted device registry entries. + + A deleted device that still belongs to a config entry is indexed by config entry id in + the base class, like an active device. An orphaned deleted device (its config entry + removed) has no config entry id and would collide with every other orphan in the base + config_entry_id=None slot, so orphans are kept out of the base index and tracked in a + separate index keyed by device id, which is unique so orphans never shadow each other. + Orphans are matched on restore by get_orphaned_entry. + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._orphaned_connections: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + self._orphaned_identifiers: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + + @override + def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None: + """Index an entry, keeping orphans in the separate id-keyed index.""" + if entry.config_entry_id is not None: + super()._index_entry(key, entry) + return + for connection in entry.connections: + self._orphaned_connections.setdefault(connection, {})[entry.id] = entry + for identifier in entry.identifiers: + self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry + + @override + def _unindex_entry( + self, key: str, replacement_entry: DeletedDeviceEntry | None = None + ) -> None: + """Unindex an entry from the base or the orphan index.""" + entry = self.data[key] + if entry.config_entry_id is not None: + super()._unindex_entry(key, replacement_entry) + return + for connection in entry.connections: + if connection in self._orphaned_connections: + del self._orphaned_connections[connection][entry.id] + if not self._orphaned_connections[connection]: + del self._orphaned_connections[connection] + for identifier in entry.identifiers: + if identifier in self._orphaned_identifiers: + del self._orphaned_identifiers[identifier][entry.id] + if not self._orphaned_identifiers[identifier]: + del self._orphaned_identifiers[identifier] def get_orphaned_entry( self, @@ -1446,8 +1761,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" devices: ActiveDeviceRegistryItems + child_devices: ChildDeviceRegistryItems deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] + _child_device_data: dict[str, ChildDeviceEntry] def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" @@ -1466,11 +1783,44 @@ def __init__(self, hass: HomeAssistant) -> None: serialize_in_event_loop=False, ) + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, + ) -> AnyDeviceEntry | None: ... + + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[False], + include_main_devices: Literal[True] = True, + ) -> DeviceEntry | None: ... + + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], + ) -> ChildDeviceEntry | None: ... + @callback - def async_get(self, device_id: str) -> DeviceEntry | None: - """Get device. + def async_get( + self, + device_id: str, + *, + include_child_devices: bool = True, + include_main_devices: bool = True, + ) -> AnyDeviceEntry | None: + """Get device or child device. - We retrieve the DeviceEntry from the underlying dict to avoid + We retrieve the entry from the underlying dicts to avoid the overhead of the UserDict __getitem__. For a pre-migration composite device id, a read-only composite device @@ -1478,10 +1828,25 @@ def async_get(self, device_id: str) -> DeviceEntry | None: device by id (e.g. in a service handler) keeps working. The composite is synthesized on demand and never stored, so it stays invisible to enumeration, identifier search and the frontend device list. + + With include_child_devices=False a child-device id resolves to None (the child + is treated as absent) and the return type excludes children. With + include_main_devices=False a main-device id (including a composite) resolves to + None and the return type excludes main devices. """ - if (device := self._device_data.get(device_id)) is not None: + if ( + include_main_devices + and (device := self._device_data.get(device_id)) is not None + ): return device - if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + if ( + include_child_devices + and (child_device := self._child_device_data.get(device_id)) is not None + ): + return child_device + if include_main_devices and ( + split_devices := self.devices.get_devices_for_composite_device_id(device_id) + ): return self._restore_composite_device(device_id, split_devices) return None @@ -1528,6 +1893,9 @@ def async_get_device( ) -> DeviceEntry | None: """Check if a device is registered. + Searches main devices only; a child device is found via + async_get_child_device_by_identifier. + Identifiers and connections are unique per config entry. If several config entries share the looked-up identifier or connection, the match is resolved to a single device when possible - preferring the device whose config entry domain @@ -1575,6 +1943,8 @@ def async_get_device_by_identifier( ) -> DeviceEntry | None: """Get the device with the identifier, owned by the config entry. + Searches main devices only; use async_get_child_device_by_identifier for a + child device. Identifiers are unique within a config entry, so unlike async_get_device the lookup cannot be ambiguous. """ @@ -1582,6 +1952,19 @@ def async_get_device_by_identifier( identifiers={identifier}, config_entry_id=config_entry_id ) + @callback + def async_get_child_device_by_identifier( + self, identifier: tuple[str, str], config_entry_id: str + ) -> ChildDeviceEntry | None: + """Get the child device with the identifier, owned by the config entry. + + Identifiers are unique within a config entry, so the lookup cannot be + ambiguous. + """ + return self.child_devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) + @callback def async_get_device_by_connection( self, connection: tuple[str, str], config_entry_id: str @@ -1605,6 +1988,8 @@ def async_get_devices( ) -> list[DeviceEntry]: """Get all devices matching any of the identifiers or connections. + Searches main devices only; a child device is found via + async_get_child_device_by_identifier. If config_entry_id is given, only devices owned by that config entry are returned. """ @@ -1752,7 +2137,7 @@ def _substitute_name_placeholders( return name @callback - def async_get_or_create( + def async_get_or_create( # noqa: C901 self, *, config_entry_id: str, @@ -1783,7 +2168,12 @@ def async_get_or_create( via_device: tuple[str, str] | UndefinedType | None = UNDEFINED, via_device_id: str | UndefinedType | None = UNDEFINED, ) -> DeviceEntry: - """Get device. Create if it doesn't exist.""" + """Get device. Create if it doesn't exist. + + To create or update a child device, use async_get_or_create_child. + + If identifiers overlap with a child device, the method raises. + """ default_manufacturer = _validate_str( "default_manufacturer", default_manufacturer ) @@ -1831,15 +2221,8 @@ def async_get_or_create( ) if translation_key: - full_translation_key = ( - f"component.{config_entry.domain}.device.{translation_key}.name" - ) - translations = translation.async_get_cached_translations( - self.hass, self.hass.config.language, "device", config_entry.domain - ) - translated_name = translations.get(full_translation_key, translation_key) - name = self._substitute_name_placeholders( - config_entry.domain, translated_name, translation_placeholders or {} + name = self._resolve_translated_name( + config_entry, translation_key, translation_placeholders ) # Reconstruct a DeviceInfo dict from the arguments. @@ -1873,6 +2256,20 @@ def async_get_or_create( else: connections = _normalize_connections(connections) + # A child is referenced via parent_device_id, not adopted by a device info + if ( + matched_child_device := self.child_devices.get_entry( + identifiers=identifiers, config_entry_id=config_entry_id + ) + ) is not None: + raise DeviceInfoError( + config_entry.domain, + device_info, + f"identifiers {sorted(identifiers)} overlap with those of child device " + f"{matched_child_device.id} with identifiers " + f"{sorted(matched_child_device.identifiers)}", + ) + device = self.devices.get_entry( connections=connections, identifiers=identifiers, @@ -1880,18 +2277,31 @@ def async_get_or_create( ) self._async_reconcile_collisions( - device, config_entry, device_info, identifiers, connections + device, + config_entry, + device_info, + identifiers, + connections, ) if device is not None: - # Reconciliation can update the matched device (e.g. detach its via link) + # Collision reconciliation can update the matched device (e.g. detach + # its via link) device = self.devices[device.id] - # Resolved after reconciliation so a removed stale duplicate can't be linked + # Resolved after collision reconciliation so a removed stale duplicate can't be + # linked if via_device_id is not UNDEFINED and via_device_id is not None: resolved_via_device_id = self._resolve_via_device_id( via_device_id, config_entry_id ) if resolved_via_device_id is None: + if via_device_id in self._child_device_data: + raise DeviceInfoError( + config_entry.domain, + device_info, + f"via_device_id {via_device_id} is a child device, which " + "can't be a via device", + ) raise DeviceInfoError( config_entry.domain, device_info, @@ -2040,36 +2450,405 @@ def async_get_or_create( identifiers_connections: dict[str, Any] has_composite_identifiers: bool | UndefinedType = UNDEFINED if device.has_composite_identifiers: - identifiers_connections = { - "new_connections": connections, - "new_identifiers": identifiers, - } - has_composite_identifiers = False + identifiers_connections = { + "new_connections": connections, + "new_identifiers": identifiers, + } + has_composite_identifiers = False + else: + identifiers_connections = { + "merge_connections": connections or UNDEFINED, + "merge_identifiers": identifiers or UNDEFINED, + } + + device = self._async_update_device( + device.id, + disabled_by=disabled_by, + entry_type=entry_type, + is_new=is_new, + name=name, + has_composite_identifiers=has_composite_identifiers, + new_config_subentry_id=config_subentry_id, + suggested_area=suggested_area, + via_device_id=via_device_id, + **identifiers_connections, + **validated_fields, + ) + + # This is safe because _async_update_device will always return a device + # in this use case. + assert device + self._live_device_ids.setdefault(device.config_entry_id, set()).add(device.id) + return device + + @callback + def _resolve_translated_name( + self, + config_entry: ConfigEntry, + translation_key: str, + translation_placeholders: Mapping[str, str] | None, + ) -> str: + """Resolve a device's translated name from its translation key.""" + full_translation_key = ( + f"component.{config_entry.domain}.device.{translation_key}.name" + ) + translations = translation.async_get_cached_translations( + self.hass, self.hass.config.language, "device", config_entry.domain + ) + translated_name = translations.get(full_translation_key, translation_key) + return self._substitute_name_placeholders( + config_entry.domain, translated_name, translation_placeholders or {} + ) + + @callback + def async_get_or_create_child( + self, + *, + config_entry_id: str, + config_subentry_id: str | UndefinedType | None = UNDEFINED, + created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + identifiers: set[tuple[str, str]], + modified_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored + name: str | UndefinedType | None = UNDEFINED, + parent_device_id: str, + suggested_area: str | UndefinedType | None = UNDEFINED, + translation_key: str | None = None, + translation_placeholders: Mapping[str, str] | None = None, + ) -> ChildDeviceEntry: + """Get child device. Create if it doesn't exist. + + If identifiers match those of an existing device, that device is converted to + a child device, preserving its id. + """ + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + raise HomeAssistantError( + f"Can't link device to unknown config entry {config_entry_id}" + ) + + if ( + config_subentry_id is not UNDEFINED + and config_subentry_id is not None + and config_subentry_id not in config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) + + if translation_key: + name = self._resolve_translated_name( + config_entry, translation_key, translation_placeholders + ) + + # Reconstruct a ChildDeviceInfo dict from the arguments, used for error reporting + # and conversion of an existing device to a child device. + device_info: DeviceInfo = { # type: ignore[assignment] + key: val + for key, val in ( + ("identifiers", identifiers), + ("name", name), + ("parent_device_id", parent_device_id), + ("suggested_area", suggested_area), + ) + if val is not UNDEFINED + } + + domain = config_entry.domain + + if not identifiers: + raise DeviceInfoError( + domain, + device_info, + "a child device must have at least one identifier", + ) + + parent = self._device_data.get(parent_device_id) + if parent is None: + if parent_device_id in self._child_device_data: + raise DeviceInfoError( + domain, + device_info, + f"parent_device_id {parent_device_id} is a child device; a " + "child device can't be the parent of another child device", + ) + raise DeviceInfoError( + domain, + device_info, + f"parent_device_id {parent_device_id} is not a registered device " + "id; the parent device must be created before its child devices", + ) + if parent.config_entry_id != config_entry_id: + raise DeviceInfoError( + domain, + device_info, + "a child device must belong to the same config entry as its " + f"parent device {parent.id}", + ) + + # Interpret not specifying a subentry as None + effective_config_subentry_id = ( + config_subentry_id if config_subentry_id is not UNDEFINED else None + ) + if effective_config_subentry_id != parent.config_subentry_id: + raise DeviceInfoError( + domain, + device_info, + "a child device must belong to the same config subentry as its " + f"parent device {parent.id}", + ) + + child_device = self.child_devices.get_entry( + identifiers=identifiers, config_entry_id=config_entry_id + ) + + # Identifiers are unique per config entry, so raise if identifiers are + # owned by another child device. + for identifier in sorted(identifiers): + if ( + other_child := self.child_devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) + ) is not None and ( + child_device is None or other_child.id != child_device.id + ): + raise DeviceInfoError( + domain, + device_info, + f"identifier {identifier} is already registered for child " + f"device {other_child.id} of the same config entry", + ) + + if child_device is not None and child_device.parent_device_id != parent.id: + raise DeviceInfoError( + domain, + device_info, + "the child device is already registered with a different parent " + f"device {child_device.parent_device_id}; reparenting is not " + "supported, remove the child device first", + ) + + matched_device: DeviceEntry | None = None + if child_device is None: + matched_device = self.devices.get_entry( + identifiers=identifiers, config_entry_id=config_entry_id + ) + + # Validate the device -> child conversion before the collision reconciliation + # below, whose stale-duplicate strips would otherwise be left applied by a later + # raise. + if matched_device is not None: + self._async_validate_device_to_child_conversion( + matched_device, parent, config_entry, device_info + ) + + self._async_reconcile_collisions( + matched_device, + config_entry, + device_info, + identifiers, + set(), + ) + if child_device is None and matched_device is not None: + # The identifiers are registered by a full device of the config entry: + # the integration split the device into child devices, so convert it, + # preserving its id. + matched_device = self.devices[matched_device.id] + child_device = self._async_convert_device_to_child( + matched_device, parent, identifiers + ) + + is_new = False + + if child_device is None: + is_new = True + + deleted_device = self.deleted_devices.get_entry( + identifiers=identifiers, + config_entry_id=config_entry_id, + ) + if deleted_device is None: + # Fall back to an orphan (its owning config entry was removed), as + # for a full device + deleted_device = self.deleted_devices.get_orphaned_entry( + identifiers, None, domain + ) + if deleted_device is None: + area_id: str | None = None + if ( + suggested_area is not None + and suggested_area is not UNDEFINED + and suggested_area != "" + ): + # Circular dep + from . import area_registry as ar # noqa: PLC0415 + + area = ar.async_get(self.hass).async_get_or_create(suggested_area) + area_id = area.id + child_device = ChildDeviceEntry( + area_id=area_id, + config_entry_id=config_entry_id, + config_subentry_id=effective_config_subentry_id, + parent_device_id=parent.id, + ) + else: + self.deleted_devices.pop(deleted_device.id) + child_device = deleted_device.to_child_device_entry( + config_entry, + effective_config_subentry_id, + identifiers, + disabled_by, + parent, + ) + disabled_by = UNDEFINED + + self.child_devices[child_device.id] = child_device + + self._async_purge_colliding_deleted_devices(child_device, identifiers, set()) + + updated_child_device = self._async_update_child_device( + child_device.id, + disabled_by=disabled_by, + is_new=is_new, + merge_identifiers=identifiers, + name=name, + ) + + # This is safe because _async_update_child_device will always return a child + # device in this use case. + assert updated_child_device + self._live_device_ids.setdefault(config_entry_id, set()).add( + updated_child_device.id + ) + return updated_child_device + + @callback + def _async_validate_device_to_child_conversion( + self, + device: DeviceEntry, + parent: DeviceEntry, + config_entry: ConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Validate converting a device to a child device. + + Run before any mutation so a rejected conversion leaves the registry + untouched. + """ + if device.id == parent.id: + raise DeviceInfoError( + config_entry.domain, device_info, "a device can't be its own parent" + ) + if self.child_devices.get_children_for_device_id(device.id): + raise DeviceInfoError( + config_entry.domain, + device_info, + f"can't convert device {device.id} to a child device: it has child " + "devices itself, and a child device can't be the parent of another " + "child device", + ) + # The caller guarantees device and parent share the config entry; only + # subentry agreement is left to check + if device.config_subentry_id != parent.config_subentry_id: + raise DeviceInfoError( + config_entry.domain, + device_info, + "a child device must belong to the same config subentry as its " + f"parent device {parent.id}", + ) + if device.id in self._live_device_ids.get(device.config_entry_id, ()): + raise DeviceInfoError( + config_entry.domain, + device_info, + "identifiers registered as a device and as a child device by the " + "same config entry", + ) + + @callback + def _async_convert_device_to_child( + self, + device: DeviceEntry, + parent: DeviceEntry, + identifiers: set[tuple[str, str]], + ) -> ChildDeviceEntry: + """Convert a device to a child device, preserving its id. + + Lets an integration that already splits its devices (linking them with + via_device) adopt child devices with no device id changes. + + The caller must have validated the conversion with + _async_validate_device_to_child_conversion. + """ + self.hass.verify_event_loop_thread("device_registry.async_get_or_create_child") + + # The update event reports the old values of every conceptually changed + # field: the fields a child device does not have change to None / empty. + changes: dict[str, Any] = {"parent_device_id": None} + for field_name in ( + "configuration_url", + "entry_type", + "hw_version", + "manufacturer", + "model", + "model_id", + "serial_number", + "sw_version", + "via_device_id", + ): + if (old_value := getattr(device, field_name)) is not None: + changes[field_name] = old_value + if device.connections: + changes["connections"] = device.connections + # Replace identifiers copied from a pre-migration composite instead of + # merging, as async_get_or_create does. Can be simplified in HA Core 2027.8. + if device.has_composite_identifiers: + new_identifiers = identifiers else: - identifiers_connections = { - "merge_connections": connections or UNDEFINED, - "merge_identifiers": identifiers or UNDEFINED, - } - - device = self._async_update_device( - device.id, + new_identifiers = device.identifiers | identifiers + if new_identifiers != device.identifiers: + changes["identifiers"] = device.identifiers + + disabled_by = device.disabled_by + if disabled_by is None and parent.disabled: + disabled_by = DeviceEntryDisabler.DEVICE + if disabled_by != device.disabled_by: + changes["disabled_by"] = device.disabled_by + + # A ChildDeviceEntry carries no composite_device_id, so a device split from a + # pre-migration composite loses that membership here: an action targeting the + # old composite id no longer reaches this split. Edge case that no longer + # applies after the composite-device removal in HA Core 2027.8. + child_device = ChildDeviceEntry( + area_id=device.area_id, + config_entry_id=device.config_entry_id, + config_subentry_id=device.config_subentry_id, + created_at=device.created_at, disabled_by=disabled_by, - entry_type=entry_type, - is_new=is_new, - name=name, - has_composite_identifiers=has_composite_identifiers, - new_config_subentry_id=config_subentry_id, - suggested_area=suggested_area, - via_device_id=via_device_id, - **identifiers_connections, - **validated_fields, + id=device.id, + identifiers=new_identifiers, # type: ignore[arg-type] + labels=device.labels, # type: ignore[arg-type] + name=device.name, + name_by_user=device.name_by_user, + parent_device_id=parent.id, ) + del self.devices[device.id] + self.child_devices[child_device.id] = child_device - # This is safe because _async_update_device will always return a device - # in this use case. - assert device - self._live_device_ids.setdefault(device.config_entry_id, set()).add(device.id) - return device + # A via_device_id must not resolve to a child device; detach inbound via + # links to the converted device, as async_remove_device does, before firing + # the conversion event. + for other_device in list(self.devices.values()): + if other_device.via_device_id == device.id: + self._async_update_device(other_device.id, via_device_id=None) + + self.async_schedule_save() + self.hass.bus.async_fire_internal( + EVENT_DEVICE_REGISTRY_UPDATED, + _EventDeviceRegistryUpdatedData_Update( + action="update", device_id=child_device.id, changes=changes + ), + ) + return child_device @callback def _async_update_device( # noqa: C901 @@ -2196,6 +2975,11 @@ def _async_update_device( # noqa: C901 and via_device_id not in self.devices and not self.devices.get_devices_for_composite_device_id(via_device_id) ): + if via_device_id in self._child_device_data: + raise HomeAssistantError( + f"via_device_id {via_device_id} is a child device, which " + "can't be a via device" + ) raise HomeAssistantError( f"Can't link device to unknown via device {via_device_id}" ) @@ -2285,6 +3069,13 @@ def _async_update_device( # noqa: C901 target_config_entry_id = move_target.config_entry_id target_config_subentry_id = move_target.config_subentry_id pending_move = None + # A parent with child devices can't move (enforced again below); reject + # here before mutating the runtime-only sibling pending moves, so the + # rejected move leaves no partial state behind. + if self.child_devices.get_children_for_device_id(device_id): + raise HomeAssistantError( + f"Can't move device {device_id}: it has child devices" + ) # A pre-migration composite's splits share identity, so once one split # completes the move to the target entry the others must not also move # there and collide; clear their pending moves. @@ -2345,6 +3136,16 @@ def _async_update_device( # noqa: C901 ) is_move = effective_config_entry_id != old.config_entry_id + # A child device lives on the same config entry and subentry as its parent, so + # a parent with child devices can't move without cascading moves, which are not + # supported. + if ( + is_move or "config_subentry_id" in new_values + ) and self.child_devices.get_children_for_device_id(device_id): + raise HomeAssistantError( + f"Can't move device {device_id}: it has child devices" + ) + if via_device_id is not UNDEFINED and via_device_id is not None: # Existence was already validated, so this cannot be None via_device_id = self._resolve_via_device_id( @@ -2420,8 +3221,8 @@ def _async_update_device( # noqa: C901 # a deleted device does) unless a consistent disabled_by was passed explicitly: # disable an enabled device moved onto a disabled entry, and clear a # CONFIG_ENTRY disable when moved onto an enabled entry. A USER disable is - # preserved. A new device is reconciled the same way, so a create can't - # leave the device's disabled state contradicting the owning entry's. + # preserved. Disable_by of a new device is handled the same way, so a create + # can't leave the device's disabled state contradicting the owning entry's. if (disabled_by is not UNDEFINED or is_move or is_new) and ( owning_entry := self.hass.config_entries.async_get_entry( effective_config_entry_id @@ -2536,6 +3337,189 @@ def _async_update_device( # noqa: C901 self.hass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data) + # Disabling a parent device disables its child devices; enabling it enables + # the child devices it disabled. CONFIG_ENTRY transitions are skipped: they + # are applied to parents and children alike by + # async_config_entry_disabled_by_changed, which iterates all the config + # entry's devices. + if "disabled_by" in old_values and ( + children := self.child_devices.get_children_for_device_id(device_id) + ): + if new.disabled_by is None: + for child in children: + if child.disabled_by is DeviceEntryDisabler.DEVICE: + self._async_update_child_device(child.id, disabled_by=None) + elif new.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY: + for child in children: + if not child.disabled: + self._async_update_child_device( + child.id, disabled_by=DeviceEntryDisabler.DEVICE + ) + + return new + + @callback + def _async_update_child_device( + self, + child_device_id: str, + *, + area_id: str | UndefinedType | None = UNDEFINED, + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + is_new: bool = False, + labels: set[str] | UndefinedType = UNDEFINED, + merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, + name_by_user: str | UndefinedType | None = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, + new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, + ) -> ChildDeviceEntry | None: + """Private update child device attributes.""" + old = self.child_devices[child_device_id] + + new_values: dict[str, Any] = {} # Dict with new key/value pairs + old_values: dict[str, Any] = {} # Dict with old key/value pairs + + if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED: + raise HomeAssistantError( + "Cannot define both merge_identifiers and new_identifiers" + ) + + if new_identifiers is not UNDEFINED and not new_identifiers: + raise HomeAssistantError("A child device must have at least one identifier") + + added_identifiers: set[tuple[str, str]] | None = None + + if merge_identifiers is not UNDEFINED: + merge_identifiers = self._validate_child_identifiers( + child_device_id, + old.config_entry_id, + merge_identifiers, + ) + old_identifiers = old.identifiers + if not merge_identifiers.issubset(old_identifiers): + added_identifiers = merge_identifiers + new_values["identifiers"] = old_identifiers | merge_identifiers + old_values["identifiers"] = old_identifiers + + elif new_identifiers is not UNDEFINED: + added_identifiers = new_values["identifiers"] = ( + self._validate_child_identifiers( + child_device_id, + old.config_entry_id, + new_identifiers, + ) + ) + old_values["identifiers"] = old.identifiers + + # An explicit disabled_by must be consistent with the disabled state of the + # owning config entry (as for a full device) and of the parent device: a child + # of a disabled parent can't be enabled, and can't be disabled by DEVICE when + # the parent is enabled. + if disabled_by is not UNDEFINED or is_new: + parent_device = self._device_data[old.parent_device_id] + owning_entry = self.hass.config_entries.async_get_entry(old.config_entry_id) + context = ( + "when creating a child device attached to" + if is_new + else "on a child device belonging to" + ) + parent_context = ( + "when creating a child device whose parent device is" + if is_new + else "on a child device whose parent device is" + ) + if owning_entry is not None: + if disabled_by is None and owning_entry.disabled_by: + report_usage( + f"sets disabled_by to None {context} the disabled " + f"config entry {old.config_entry_id}", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8", + ) + disabled_by = UNDEFINED + elif ( + disabled_by is DeviceEntryDisabler.CONFIG_ENTRY + and not owning_entry.disabled_by + ): + report_usage( + f"sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY " + f"{context} the enabled config entry {old.config_entry_id}", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8", + ) + disabled_by = UNDEFINED + if is_new and disabled_by is UNDEFINED: + if owning_entry.disabled_by: + if old.disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + if disabled_by is DeviceEntryDisabler.DEVICE and not parent_device.disabled: + report_usage( + f"sets disabled_by to DeviceEntryDisabler.DEVICE " + f"{parent_context} enabled", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8", + ) + disabled_by = UNDEFINED + # Report an external attempt to enable a child whose parent stays disabled. + if ( + disabled_by is None + and parent_device.disabled + and old.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY + ): + report_usage( + f"sets disabled_by to None {parent_context} disabled", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8", + ) + # Coerce the child back to a parent-derived DEVICE disable, keeping it + # consistent with its disabled parent. + if parent_device.disabled and ( + disabled_by is None + or (is_new and disabled_by is UNDEFINED and old.disabled_by is None) + ): + disabled_by = DeviceEntryDisabler.DEVICE + + for attr_name, value in ( + ("area_id", area_id), + ("disabled_by", disabled_by), + ("labels", labels), + ("name", name), + ("name_by_user", name_by_user), + ): + if value is not UNDEFINED and value != getattr(old, attr_name): + new_values[attr_name] = value + old_values[attr_name] = getattr(old, attr_name) + + if not new_values and not is_new: + return old + + new_values["modified_at"] = utcnow() + + self.hass.verify_event_loop_thread("device_registry._async_update_child_device") + new = attr.evolve(old, **new_values) + self.child_devices[child_device_id] = new + + # A deleted device holding an identity the child device now owns can never + # restore + for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + added_identifiers or set(), + set(), + config_entry_id=old.config_entry_id, + exclude_device_id=None, + ): + del self.deleted_devices[deleted_device_id] + + self.async_schedule_save() + + data: EventDeviceRegistryUpdatedData + if is_new: + data = {"action": "create", "device_id": new.id} + else: + data = {"action": "update", "device_id": new.id, "changes": old_values} + + self.hass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data) + return new @callback @@ -2572,6 +3556,9 @@ def async_update_device( ) -> DeviceEntry | None: """Update device attributes. + This updates a main device. To update a child device, use + async_update_child_device. + A device belongs to a single config entry and subentry. To move a device to another config entry or subentry, pass new_config_entry_id and/or new_config_subentry_id. To remove a device, call async_remove_device. @@ -2692,6 +3679,39 @@ def async_update_device( **validated_fields, ) + @callback + def async_update_child_device( + self, + device_id: str, + *, + area_id: str | UndefinedType | None = UNDEFINED, + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, + name_by_user: str | UndefinedType | None = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, + new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, + ) -> ChildDeviceEntry: + """Update child device attributes. + + :param disabled_by: Disable or enable the child device. Must be consistent with + the disabled state of the config entry owning the child device and of its + parent device: a child device can't be enabled when either is disabled. An + inconsistent disabled_by is deprecated and ignored; this will raise in HA + Core 2027.8. + """ + updated = self._async_update_child_device( + device_id, + area_id=area_id, + disabled_by=disabled_by, + labels=labels, + name_by_user=name_by_user, + name=name, + new_identifiers=new_identifiers, + ) + if TYPE_CHECKING: + assert updated is not None + return updated + @callback def _async_reconcile_collisions( self, @@ -2758,12 +3778,14 @@ def _async_reconcile_collisions( @callback def _async_purge_colliding_deleted_devices( self, - device: DeviceEntry, + device: AnyDeviceEntry, identifiers: set[tuple[str, str]], connections: set[tuple[str, str]], ) -> None: """Purge deleted devices with key collisions.""" - if not device.has_composite_identifiers: + if isinstance(device, ChildDeviceEntry): + identifiers = device.identifiers | identifiers + elif not device.has_composite_identifiers: identifiers = device.identifiers | identifiers connections = device.connections | connections colliding = self.deleted_devices.get_colliding_device_ids( @@ -2842,6 +3864,40 @@ def _validate_identifiers( ) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) + if ( + existing_child_device := self.child_devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) + ) is not None: + raise DeviceIdentifierCollisionError(identifiers, existing_child_device) + + return identifiers + + @callback + def _validate_child_identifiers( + self, + child_device_id: str, + config_entry_id: str, + identifiers: set[tuple[str, str]], + ) -> set[tuple[str, str]]: + """Validate child device identifiers, raise on collision. + + Identifiers are unique per config entry, in a namespace shared between + devices and child devices. + """ + for identifier in identifiers: + if ( + existing_child_device := self.child_devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) + ) and existing_child_device.id != child_device_id: + raise DeviceIdentifierCollisionError(identifiers, existing_child_device) + if ( + existing_device := self.devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) + ) is not None: + raise DeviceIdentifierCollisionError(identifiers, existing_device) return identifiers @@ -2887,7 +3943,10 @@ def _async_update_composite_device( @callback def async_remove_device(self, device_id: str) -> None: - """Remove a device from the device registry.""" + """Remove a device or child device from the device registry.""" + if (child_device := self._child_device_data.get(device_id)) is not None: + self._async_remove_child_device(child_device) + return if ( underlying_ids := self._async_device_ids_for_composite_device_id(device_id) ) is not None: @@ -2895,6 +3954,9 @@ def async_remove_device(self, device_id: str) -> None: self.async_remove_device(underlying_id) return self.hass.verify_event_loop_thread("device_registry.async_remove_device") + # Removing the parent removes its child devices + for child in self.child_devices.get_children_for_device_id(device_id): + self._async_remove_child_device(child) device = self.devices.pop(device_id) config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) self.deleted_devices[device_id] = DeletedDeviceEntry( @@ -2923,6 +3985,39 @@ def async_remove_device(self, device_id: str) -> None: ) self.async_schedule_save() + @callback + def _async_remove_child_device(self, child_device: ChildDeviceEntry) -> None: + """Remove a child device from the device registry.""" + self.hass.verify_event_loop_thread("device_registry.async_remove_device") + del self.child_devices[child_device.id] + config_entry = self.hass.config_entries.async_get_entry( + child_device.config_entry_id + ) + self.deleted_devices[child_device.id] = DeletedDeviceEntry( + area_id=child_device.area_id, + config_entry_id=child_device.config_entry_id, + config_subentry_id=child_device.config_subentry_id, + connections=set(), + created_at=child_device.created_at, + disabled_by=child_device.disabled_by, + identifiers=child_device.identifiers, + id=child_device.id, + labels=child_device.labels, + modified_at=utcnow(), + name_by_user=child_device.name_by_user, + orphaned_timestamp=None, + domain=config_entry.domain if config_entry is not None else None, + ) + self.hass.bus.async_fire_internal( + EVENT_DEVICE_REGISTRY_UPDATED, + _EventDeviceRegistryUpdatedData_Remove( + action="remove", + device_id=child_device.id, + device=child_device.dict_repr, + ), + ) + self.async_schedule_save() + @override async def _async_load(self) -> None: """Load the device registry.""" @@ -2934,7 +4029,9 @@ async def _async_load(self) -> None: data = await self._store.async_load() devices = ActiveDeviceRegistryItems() + child_devices = ChildDeviceRegistryItems() deleted_devices = DeletedDeviceRegistryItems() + child_devices_dropped = False if data is not None: for device in data["devices"]: @@ -2987,6 +4084,42 @@ async def _async_load(self) -> None: via_device_id=device["via_device_id"], ) + for child_device in data["child_devices"]: + # The remove cascade makes a child without its parent impossible; + # guard against a manually edited or corrupted store anyway. + if ( + parent_device_id := child_device["parent_device_id"] + ) not in devices: + _LOGGER.error( + "Dropping child device %s: its parent device %s is not in " + "the device registry", + child_device["id"], + parent_device_id, + ) + child_devices_dropped = True + continue + child_devices[child_device["id"]] = ChildDeviceEntry( + area_id=child_device["area_id"], + config_entry_id=child_device["config_entry_id"], + config_subentry_id=child_device["config_subentry_id"], + created_at=datetime.fromisoformat(child_device["created_at"]), + disabled_by=( + DeviceEntryDisabler(child_device["disabled_by"]) + if child_device["disabled_by"] + else None + ), + id=child_device["id"], + identifiers={ + tuple(iden) # type: ignore[misc] + for iden in child_device["identifiers"] + }, + labels=set(child_device["labels"]), + modified_at=datetime.fromisoformat(child_device["modified_at"]), + name_by_user=child_device["name_by_user"], + name=child_device["name"], + parent_device_id=parent_device_id, + ) + # Introduced in 0.111 def get_optional_enum[_EnumT: StrEnum]( cls: type[_EnumT], value: str | None, undefined: bool @@ -3034,8 +4167,15 @@ def get_optional_enum[_EnumT: StrEnum]( ) self.devices = devices + self.child_devices = child_devices self.deleted_devices = deleted_devices self._device_data = devices.data + self._child_device_data = child_devices.data + + # Persist dropped corrupt/orphaned children so the store isn't left dirty until + # an unrelated write + if child_devices_dropped: + self.async_schedule_save() self._loaded_event.set() @@ -3053,6 +4193,9 @@ def _data_to_save(self) -> dict[str, Any]: "devices": [ entry.as_storage_fragment for entry in list(self.devices.values()) ], + "child_devices": [ + entry.as_storage_fragment for entry in list(self.child_devices.values()) + ], "deleted_devices": [ entry.as_storage_fragment for entry in list(self.deleted_devices.values()) @@ -3117,6 +4260,12 @@ def async_clear_config_entry( now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): self.async_remove_device(device.id) + # Child devices share their parent's config entry, so the loop above removes + # them through the parent cascade; guard against store corruption anyway. + for child_device in self.child_devices.get_devices_for_config_entry_id( + config_entry_id + ): + self.async_remove_device(child_device.id) # A split device records the composite's former primary config entry; when that # config entry is removed, clear the now-dangling reference so a restored # composite no longer points at a config entry that no longer exists. @@ -3152,6 +4301,14 @@ def async_clear_config_subentry( if device.config_subentry_id != config_subentry_id: continue self.async_remove_device(device.id) + # Child devices share their parent's subentry, so the loop above removes them + # through the parent cascade; guard against store corruption anyway. + for child_device in self.child_devices.get_devices_for_config_entry_id( + config_entry_id + ): + if child_device.config_subentry_id != config_subentry_id: + continue + self.async_remove_device(child_device.id) # A device may hold a transient pending move targeting the subentry being removed; # clear it so a later completion deletes the device instead of validating against # the removed subentry. @@ -3194,6 +4351,8 @@ def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" for device in self.devices.get_devices_for_area_id(area_id): self._async_update_device(device.id, area_id=None) + for child_device in self.child_devices.get_devices_for_area_id(area_id): + self._async_update_child_device(child_device.id, area_id=None) for deleted_device in list(self.deleted_devices.values()): if deleted_device.area_id != area_id: continue @@ -3207,6 +4366,10 @@ def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" for device in self.devices.get_devices_for_label(label_id): self._async_update_device(device.id, labels=device.labels - {label_id}) + for child_device in self.child_devices.get_devices_for_label(label_id): + self._async_update_child_device( + child_device.id, labels=child_device.labels - {label_id} + ) for deleted_device in list(self.deleted_devices.values()): if label_id not in deleted_device.labels: continue @@ -3231,6 +4394,8 @@ def async_get_device_id_by_identifier( ) -> str: """Get the id of the device with the identifier, owned by the config entry. + Searches main devices only; use async_get_child_device_by_identifier for a + child device. Convenience wrapper for linking a device to its via device through via_device_id. Identifiers are unique within a config entry, so the lookup cannot be ambiguous. @@ -3259,17 +4424,61 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: @callback -def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[DeviceEntry]: - """Return entries that match an area.""" - return registry.devices.get_devices_for_area_id(area_id) +def async_entries_for_area( + registry: DeviceRegistry, area_id: str +) -> list[AnyDeviceEntry]: + """Return entries whose effective area matches the area. + + Includes child devices with the area set explicitly, and child devices + inheriting the area from their parent device. + """ + devices = registry.devices.get_devices_for_area_id(area_id) + entries: list[AnyDeviceEntry] = list(devices) + entries.extend(registry.child_devices.get_devices_for_area_id(area_id)) + for device in devices: + entries.extend( + child_device + for child_device in registry.child_devices.get_children_for_device_id( + device.id + ) + if child_device.area_id is None + ) + return entries + + +@callback +def async_get_effective_area_id( + hass: HomeAssistant, device: AnyDeviceEntry +) -> str | None: + """Return the effective area of a device or child device. + + A child device without an area of its own inherits its parent's area. + """ + if device.area_id is not None: + return device.area_id + if isinstance(device, ChildDeviceEntry): + registry = async_get(hass) + if parent := registry.async_get( + device.parent_device_id, include_child_devices=False + ): + return parent.area_id + return None @callback def async_entries_for_label( registry: DeviceRegistry, label_id: str -) -> list[DeviceEntry]: - """Return entries that match a label.""" - return registry.devices.get_devices_for_label(label_id) +) -> list[AnyDeviceEntry]: + """Return entries that match a label. + + Includes child devices carrying the label; labels are never inherited from the + parent, so a child appears here only when the label is set on the child itself. + """ + entries: list[AnyDeviceEntry] = list( + registry.devices.get_devices_for_label(label_id) + ) + entries.extend(registry.child_devices.get_devices_for_label(label_id)) + return entries @callback @@ -3280,6 +4489,22 @@ def async_entries_for_config_entry( return registry.devices.get_devices_for_config_entry_id(config_entry_id) +@callback +def async_entries_for_parent_device( + registry: DeviceRegistry, parent_device_id: str +) -> list[ChildDeviceEntry]: + """Return the child device entries of a parent device.""" + return registry.child_devices.get_children_for_device_id(parent_device_id) + + +@callback +def async_child_entries_for_config_entry( + registry: DeviceRegistry, config_entry_id: str +) -> list[ChildDeviceEntry]: + """Return child device entries that match a config entry.""" + return registry.child_devices.get_devices_for_config_entry_id(config_entry_id) + + @callback def async_config_entry_disabled_by_changed( registry: DeviceRegistry, config_entry: ConfigEntry @@ -3292,22 +4517,33 @@ def async_config_entry_disabled_by_changed( DeviceEntryDisabler.CONFIG_ENTRY. """ - devices = async_entries_for_config_entry(registry, config_entry.entry_id) + devices: list[AnyDeviceEntry] = [ + *async_entries_for_config_entry(registry, config_entry.entry_id), + *async_child_entries_for_config_entry(registry, config_entry.entry_id), + ] if not config_entry.disabled_by: for device in devices: if device.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY: continue - registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 + if isinstance(device, ChildDeviceEntry): + registry._async_update_child_device(device.id, disabled_by=None) # noqa: SLF001 + else: + registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 return for device in devices: if device.disabled: # Device already disabled, do not overwrite continue - registry._async_update_device( # noqa: SLF001 - device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY - ) + if isinstance(device, ChildDeviceEntry): + registry._async_update_child_device( # noqa: SLF001 + device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY + ) + else: + registry._async_update_device( # noqa: SLF001 + device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY + ) @callback @@ -3367,6 +4603,25 @@ def async_cleanup( device.id, remove_config_entry_id=device.config_entry_id ) + # A child device shares its parent's (valid) config entry, and the remove cascade + # makes a child without its parent impossible; guard against store corruption anyway. + for child_device in list(dev_reg.child_devices.values()): + if child_device.parent_device_id not in dev_reg.devices: + _LOGGER.error( + "Removing child device %s: its parent device %s is not in the " + "device registry", + child_device.id, + child_device.parent_device_id, + ) + dev_reg.async_remove_device(child_device.id) + elif child_device.config_entry_id not in config_entry_ids: + _LOGGER.error( + "Removing child device %s: its config entry %s no longer exists", + child_device.id, + child_device.config_entry_id, + ) + dev_reg.async_remove_device(child_device.id) + # Periodic purge of orphaned devices to avoid the registry # growing without bounds when there are lots of deleted devices dev_reg.async_purge_expired_orphaned_devices() diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4ffd4504f8ed4a..292d37ebcbc435 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -57,7 +57,7 @@ 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 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: 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. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7adbb90ad2fdd2..50700eecb0615a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -5,7 +5,7 @@ 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 @@ async def _async_add_entity( # noqa: C901 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", diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 11401aa7e5ec7a..b411623ced37ed 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -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 @@ def async_device_modified( ) 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 @@ def _split_device_id( ) -> 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 diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 5d4807aa53f0ee..c6ceb6508f6876 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -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, ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 1a8528c8f03946..31d2bf26a69c36 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -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( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5bbb2563bcdb9d..5242ae951b8601 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -264,7 +264,9 @@ async def async_call( 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) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 518ebd17016d6d..192226e7a33c13 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -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: diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index d34151002f113e..348b2da7bd94e0 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -155,6 +155,45 @@ def log_missing(self, missing_entities: set[str], logger: Logger) -> None: ) +@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 _include_entry(entry: er.RegistryEntry) -> bool: 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 @@ def _setup_registry_listeners(self) -> None: # 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 diff --git a/homeassistant/helpers/template/extensions/areas.py b/homeassistant/helpers/template/extensions/areas.py index 226be1a464c2cb..5f446568f6a4f1 100644 --- a/homeassistant/helpers/template/extensions/areas.py +++ b/homeassistant/helpers/template/extensions/areas.py @@ -105,12 +105,14 @@ def area_name(self, lookup_value: str) -> str | None: 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 diff --git a/homeassistant/helpers/template/extensions/devices.py b/homeassistant/helpers/template/extensions/devices.py index 910e7ba0f0cce8..17f7b521337ee0 100644 --- a/homeassistant/helpers/template/extensions/devices.py +++ b/homeassistant/helpers/template/extensions/devices.py @@ -85,7 +85,8 @@ def device_id(self, entity_id_or_device_name: str) -> str | None: 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) ), diff --git a/homeassistant/helpers/template/helpers.py b/homeassistant/helpers/template/helpers.py index 286657cd2afa32..039b6c40b5376b 100644 --- a/homeassistant/helpers/template/helpers.py +++ b/homeassistant/helpers/template/helpers.py @@ -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 diff --git a/homeassistant/loader.py b/homeassistant/loader.py index dd9f03d42663c1..780198b6dac5e3 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -406,9 +406,13 @@ async def async_remove_config_entry_device( 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 diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 16dabcd43b213d..0682a11666bbc4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.0 +uv==0.12.2 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index eca251b9ab53b7..3c5e3c868d864d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.0", + "uv==0.12.2", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index c218f5b3f1d67a..b8330379aaf5d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.0 +uv==0.12.2 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index 8a606819d4f70c..3c0abcc201aabd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ aiotedee==0.3.0 aiotractive==1.0.3 # homeassistant.components.unifi -aiounifi==91 +aiounifi==92 # homeassistant.components.usb aiousbwatcher==1.1.2 @@ -3175,7 +3175,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.8.2 +tesla-fleet-api==1.9.0 # homeassistant.components.powerwall tesla-powerwall==0.5.3 diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index df30a4b766dc75..20f883fc870df8 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -9,7 +9,7 @@ ) 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 diff --git a/tests/common.py b/tests/common.py index 3422d1b4b866f7..9966c166cca957 100644 --- a/tests/common.py +++ b/tests/common.py @@ -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(): diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 6b4a17fc288fd5..dc7932e2cb2119 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -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, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index ddd67a886fbe56..c0f41bb6f2c323 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -14,7 +14,11 @@ DocumentBlock, EncryptedCodeExecutionResultBlock, Message, + MessageDeltaUsage, PlainTextSource, + RawMessageDeltaEvent, + RawMessageStartEvent, + RawMessageStopEvent, ServerToolCaller20260120, TextBlock, TextEditorCodeExecutionCreateResultBlock, @@ -29,6 +33,7 @@ WebSearchResultBlock, WebSearchToolResultError, ) +from anthropic.types.raw_message_delta_event import Delta from anthropic.types.text_editor_code_execution_tool_result_block import ( Content as TextEditorCodeExecutionToolResultBlockContent, ) @@ -62,6 +67,7 @@ ContentDetails, _convert_content, ) +from homeassistant.components.conversation import trace from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.llm import LLMTools @@ -261,6 +267,71 @@ async def test_conversation_agent( assert agent.supported_languages == "*" +async def test_token_stats_reported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, +) -> None: + """Test that cache reads, not cache creation, are reported as cached tokens.""" + trace.async_clear_traces() + + async def mock_stream(**kwargs: Any): + """Stream a single response carrying distinct cache read and creation usage.""" + yield RawMessageStartEvent( + type="message_start", + message=Message( + type="message", + id="msg_1234567890ABCDEFGHIJKLMN", + content=[], + role="assistant", + model=kwargs["model"], + usage=Usage( + input_tokens=100, + output_tokens=0, + cache_creation_input_tokens=20, + cache_read_input_tokens=80, + ), + ), + ) + for event in create_content_block(0, ["ok"]): + yield event + yield RawMessageDeltaEvent( + type="message_delta", + delta=Delta(stop_reason="end_turn", stop_sequence=""), + usage=MessageDeltaUsage(output_tokens=10), + ) + yield RawMessageStopEvent(type="message_stop") + + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=mock_stream, + ): + await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + trace_obj = next(iter(trace.async_get_traces())) + events = trace_obj.as_dict().get("events", []) + stats = next( + event["data"]["stats"] + for event in events + if event.get("event_type") == "agent_detail" + and event.get("data", {}).get("stats") + ) + # cache_read_input_tokens (80) is the served-from-cache count, distinct from + # cache_creation_input_tokens (20); only the read count should surface as cached. + assert stats == { + "input_tokens": 100, + "cached_input_tokens": 80, + "output_tokens": 10, + } + + async def test_prompt_caching_system_prompt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 3a85803d721996..d6d03c5bfa9b8b 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -2036,6 +2036,91 @@ def _reset() -> None: 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, diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 1c3e1e4bfc9a28..55da9401749cc1 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -99,10 +99,9 @@ async def test_flow_works( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -131,9 +130,13 @@ async def test_flow_with_connection_failure( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["errors"] == {"base": "cannot_connect"} @@ -146,9 +149,13 @@ async def test_flow_with_api_failure(hass: HomeAssistant, product_class_mock) -> ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["errors"] == {"base": "cannot_connect"} @@ -160,9 +167,13 @@ async def test_flow_with_unknown_failure( with product_class_mock as products_class: products_class.async_from_host = AsyncMock(side_effect=RuntimeError) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["errors"] == {"base": "unknown"} @@ -177,9 +188,13 @@ async def test_flow_with_unsupported_version( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["errors"] == {"base": "unsupported_version"} @@ -192,9 +207,13 @@ async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["errors"] == {"base": "invalid_auth"} @@ -215,9 +234,13 @@ async def test_already_configured(hass: HomeAssistant, valid_feature_mock) -> No await hass.async_block_till_done() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "address_already_configured" diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index e458f27c26adb9..be3df386df705b 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -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, diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index f1a81250c606c0..22d1b3d6e119f0 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -69,12 +69,11 @@ def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo: # Helper functions to reduce repetition -async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None): +async def _init_user_flow(hass: HomeAssistant): """Initialize a user config flow.""" return await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=user_input, ) @@ -255,8 +254,12 @@ async def test_connection_error( """Test we show user form on BSBLan connection error.""" mock_bsblan.device.side_effect = BSBLANConnectionError - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "127.0.0.1", CONF_PORT: 80, @@ -284,10 +287,13 @@ async def test_authentication_error( CONF_PASSWORD: "wrongpassword", } - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( + hass, + result["flow_id"], + user_input, ) assert result.get("type") is FlowResultType.FORM @@ -332,8 +338,12 @@ async def test_authentication_error_vs_connection_error( # Test connection error first mock_bsblan.device.side_effect = BSBLANConnectionError - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "127.0.0.1", CONF_PORT: 80, @@ -345,8 +355,12 @@ async def test_authentication_error_vs_connection_error( # Reset and test authentication error mock_bsblan.device.side_effect = BSBLANAuthError - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "127.0.0.1", CONF_PORT: 80, @@ -366,8 +380,12 @@ async def test_user_device_exists_abort( """Test we abort flow if BSBLAN device already configured.""" mock_config_entry.add_to_hass(hass) - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "127.0.0.1", CONF_PORT: 80, @@ -591,8 +609,12 @@ async def test_user_flow_can_update_existing_host_port( entry.add_to_hass(hass) # Try to configure the same device with different host/port via user flow - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "10.0.2.60", # Different IP CONF_PORT: 80, # Different port @@ -675,8 +697,12 @@ async def test_connection_error_recovery( # First attempt fails with connection error mock_bsblan.device.side_effect = BSBLANConnectionError - result = await _init_user_flow( + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( hass, + result["flow_id"], { CONF_HOST: "127.0.0.1", CONF_PORT: 80, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index ae8d9305c8245e..de5a33b4df3073 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -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 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 56bdd0600ebf31..21f0fa92837b4a 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -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 @@ class Unserializable: "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": []} diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index bdbe9b0ccadedb..c6629e205994b4 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -18,7 +18,10 @@ 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()) diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index b79dfa06c40126..9f7c3a2cc23be8 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -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. diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 1a0d97cf7bf3f5..d770c6bc002d26 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -262,11 +262,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'data_rate', - : 'Mock Title PLC downlink PHY rate (test2)', + : 'Mock Title PLC uplink PHY rate (test2)', : , }), 'context': , - 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'entity_id': 'sensor.mock_title_plc_uplink_phy_rate_test2', 'last_changed': , 'last_reported': , 'last_updated': , @@ -287,7 +287,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'entity_id': 'sensor.mock_title_plc_uplink_phy_rate_test2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -295,7 +295,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'PLC downlink PHY rate (test2)', + 'object_id_base': 'PLC uplink PHY rate (test2)', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 0, @@ -303,13 +303,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'PLC downlink PHY rate (test2)', + 'original_name': 'PLC uplink PHY rate (test2)', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'plc_rx_rate', - 'unique_id': '1234567890_plc_rx_rate_00:00:5E:00:53:02', + 'translation_key': 'plc_tx_rate', + 'unique_id': '1234567890_plc_tx_rate_00:00:5E:00:53:02', 'unit_of_measurement': , }) # --- diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 1d5d08407ad258..fa2f4ceadb2c29 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -159,8 +159,8 @@ async def test_update_plc_phyrates( assert hass.states.get(entity_id_downlink) == snapshot assert entity_registry.async_get(entity_id_downlink) == snapshot - assert hass.states.get(entity_id_downlink) == snapshot - assert entity_registry.async_get(entity_id_downlink) == snapshot + assert hass.states.get(entity_id_uplink) == snapshot + assert entity_registry.async_get(entity_id_uplink) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index a5451e5332d50f..6157e137279638 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -17,7 +17,11 @@ 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: diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index e0dad7c41b701c..993a0ee7813fcf 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -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, diff --git a/tests/components/home_connect/test_climate.py b/tests/components/home_connect/test_climate.py index b26ec2941f4d58..33e0c86bb0ea98 100644 --- a/tests/components/home_connect/test_climate.py +++ b/tests/components/home_connect/test_climate.py @@ -38,13 +38,19 @@ ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_PRESET_MODES, + ATTR_TARGET_TEMP_STEP, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, ClimateEntityFeature, HVACMode, ) @@ -57,6 +63,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_UNAVAILABLE, @@ -690,6 +697,277 @@ async def test_set_preset_mode_raises_home_assistant_error_on_api_errors( ) +@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True) +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ( + "min_temperature", + "expected_min_temperature", + "max_temperature", + "expected_max_temperature", + "temperature_stepsize", + "temperature_unit", + "target_temperature", + "expected_temperature_call", + ), + [ + (None, DEFAULT_MIN_TEMP, None, DEFAULT_MAX_TEMP, None, None, 20, 20), + (None, DEFAULT_MIN_TEMP, None, DEFAULT_MAX_TEMP, None, "°C", 20, 20.0), + (None, DEFAULT_MIN_TEMP, None, DEFAULT_MAX_TEMP, None, "°F", 20, 68.0), + (16, 16, 30, 30, 1, "°C", 20, 20.0), + (15.5, 15.5, 25.5, 25.5, 0.5, "°C", 20.5, 20.5), + (20, -6.7, 80, 26.7, 1, "°F", 20, 68.0), + ], +) +async def test_set_temperature_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + min_temperature: float | None, + expected_min_temperature: float | None, + max_temperature: float | None, + expected_max_temperature: float | None, + temperature_stepsize: float | None, + temperature_unit: str | None, + target_temperature: float, + expected_temperature_call: float, + appliance: HomeAppliance, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, +) -> None: + """Test temperature option functionality.""" + entity_id = "climate.air_conditioner" + option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=temperature_unit, + constraints=ProgramDefinitionConstraints( + min=min_temperature, + max=max_temperature, + step_size=temperature_stepsize, + ), + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes[ATTR_MIN_TEMP] == expected_min_temperature + assert entity_state.attributes[ATTR_MAX_TEMP] == expected_max_temperature + assert entity_state.attributes.get(ATTR_TARGET_TEMP_STEP) == temperature_stepsize + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: target_temperature, + }, + ) + await hass.async_block_till_done() + + called_mock.assert_called_once_with( + appliance.ha_id, option_key=option_key, value=expected_temperature_call + ) + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes[ATTR_TEMPERATURE] == target_temperature + + +@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True) +async def test_set_temperature_raises_home_assistant_error_on_api_errors( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test that setting a temperature raises HomeAssistantError on API errors.""" + entity_id = "climate.air_conditioner" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, + options=[ + ProgramDefinitionOption( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + "Double", + unit="°C", + constraints=ProgramDefinitionConstraints( + min=16.0, + max=30.0, + step_size=1, + ), + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + client.set_active_program_option.side_effect = HomeConnectError("Test error") + with pytest.raises(HomeAssistantError, match="Test error"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 20.0, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True) +async def test_temperature_feature_supported( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that temperature feature is supported depending on the temperature option availability.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, + options=[ + ProgramDefinitionOption( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + "Double", + unit="°C", + constraints=ProgramDefinitionConstraints( + min=16.0, + max=30.0, + step_size=1, + ), + ) + ], + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = "climate.air_conditioner" + state = hass.states.get(entity_id) + assert state + + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + & ClimateEntityFeature.TARGET_TEMPERATURE + ) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[], + ) + ) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN.value, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert ( + not state.attributes[ATTR_SUPPORTED_FEATURES] + & ClimateEntityFeature.TARGET_TEMPERATURE + ) + + +@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True) +async def test_no_issue_with_null_temperature( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test that there is no issue when the temperature option returns a null value. + + Because this test does not contain any event, the option getter will be None, + and therefore the temperature will be None. + """ + entity_id = "climate.air_conditioner" + option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit="°C", + constraints=ProgramDefinitionConstraints( + min=16.0, + max=30.0, + step_size=1, + ), + ) + ], + ) + ) + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_TEMPERATURE] is None + + @pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True) @pytest.mark.parametrize( ( @@ -771,7 +1049,7 @@ async def test_fan_mode_functionality( ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, options=[ ProgramDefinitionOption( - OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + option_key, "Enumeration", constraints=ProgramDefinitionConstraints( allowed_values=allowed_values diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index 48e26c2ae6aec1..807491e1019224 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -566,3 +566,337 @@ async def test_not_possible_to_use_favorite_program( }, blocking=True, ) + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("temperature_option", "service_option"), + [ + pytest.param( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE, + "heating_ventilation_air_conditioning_air_conditioner_option_setpoint_temperature", + id="air_conditioner_setpoint_temperature", + ), + pytest.param( + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + "cooking_oven_option_setpoint_temperature", + id="oven_setpoint_temperature", + ), + ], +) +@pytest.mark.parametrize( + ("affects_to", "program", "get_program_information_method", "method_call"), + [ + pytest.param( + "active_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "start_program", + id="start_program", + ), + pytest.param( + "selected_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "set_selected_program", + id="select_program", + ), + pytest.param( + "active_program", + None, + "get_active_program", + "set_active_program_options", + id="set_active_program_options", + ), + pytest.param( + "selected_program", + None, + "get_selected_program", + "set_selected_program_options", + id="set_selected_program_options", + ), + ], +) +@pytest.mark.parametrize( + ("option_units", "expected_value"), + [ + pytest.param("°C", 35, id="celsius"), + pytest.param("°F", 95, id="fahrenheit"), + ], +) +async def test_temperature_options_convert( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + affects_to: str, + program: str | None, + get_program_information_method: str, + method_call: str, + temperature_option: OptionKey, + service_option: str, + option_units: str, + expected_value: int, +) -> None: + """Test that temperature options are converted correctly. + + Note: The program and the options used in this test aren't related. + """ + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + async def test(args: Any, kwargs: Any) -> None: + pass + + get_program_information_method_mock = AsyncMock( + return_value=Program( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + options=[Option(key=temperature_option, value=0, unit=option_units)], + ), + wraps=test, + ) + setattr( + client, + get_program_information_method, + get_program_information_method_mock, + ) + # start_program and set_selected_program side effects + # does break the await counts. To avoid that mocks are reset. + setattr(client, method_call, AsyncMock()) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + service_data = { + "device_id": device_entry.id, + "affects_to": affects_to, + service_option: 35, + } + if program: + service_data["program"] = program + + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + service_data, + blocking=True, + ) + await hass.async_block_till_done() + + kwargs = getattr(client, method_call).call_args.kwargs + call_options: list[Option] = ( + kwargs.get("options") or kwargs["array_of_options"].options + ) + assert call_options[0].value == expected_value + get_program_information_method_mock.assert_awaited_once() + assert get_program_information_method_mock.await_args.args[0] == appliance.ha_id + assert get_program_information_method_mock.await_args.kwargs.get("program_key") is ( + ProgramKey.DISHCARE_DISHWASHER_ECO_50 if program else None + ) + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + "service_option", + [ + pytest.param( + "heating_ventilation_air_conditioning_air_conditioner_option_setpoint_temperature", + id="air_conditioner_setpoint_temperature", + ), + pytest.param( + "cooking_oven_option_setpoint_temperature", + id="oven_setpoint_temperature", + ), + ], +) +@pytest.mark.parametrize( + ("affects_to", "program", "get_program_information_method", "method_call"), + [ + pytest.param( + "active_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "start_program", + id="start_program", + ), + pytest.param( + "selected_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "set_selected_program", + id="select_program", + ), + pytest.param( + "active_program", + None, + "get_active_program", + "set_active_program_options", + id="set_active_program_options", + ), + pytest.param( + "selected_program", + None, + "get_selected_program", + "set_selected_program_options", + id="set_selected_program_options", + ), + ], +) +@pytest.mark.parametrize("options", [[], None]) +async def test_temperature_options_convert_missing_option( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + affects_to: str, + program: str | None, + get_program_information_method: str, + method_call: str, + service_option: str, + options: list[Option] | None, +) -> None: + """Test that default units are used when the option is missing. + + Note: The program and the options used in this test aren't related. + """ + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + setattr( + client, + get_program_information_method, + AsyncMock( + return_value=Program( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, options=options + ) + ), + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + service_data = { + "device_id": device_entry.id, + "affects_to": affects_to, + service_option: 35, + } + if program: + service_data["program"] = program + + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + service_data, + blocking=True, + ) + await hass.async_block_till_done() + + kwargs = getattr(client, method_call).call_args.kwargs + call_options: list[Option] = ( + kwargs.get("options") or kwargs["array_of_options"].options + ) + assert call_options[0].value == 35 + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + "service_option", + [ + pytest.param( + "heating_ventilation_air_conditioning_air_conditioner_option_setpoint_temperature", + id="air_conditioner_setpoint_temperature", + ), + pytest.param( + "cooking_oven_option_setpoint_temperature", + id="oven_setpoint_temperature", + ), + ], +) +@pytest.mark.parametrize( + ("affects_to", "program", "get_program_information_method", "method_call"), + [ + pytest.param( + "active_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "start_program", + id="start_program", + ), + pytest.param( + "selected_program", + "dishcare_dishwasher_program_eco_50", + "get_available_program", + "set_selected_program", + id="select_program", + ), + pytest.param( + "active_program", + None, + "get_active_program", + "set_active_program_options", + id="set_active_program_options", + ), + pytest.param( + "selected_program", + None, + "get_selected_program", + "set_selected_program_options", + id="set_selected_program_options", + ), + ], +) +async def test_temperature_options_convert_api_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + affects_to: str, + program: str | None, + get_program_information_method: str, + method_call: str, + service_option: str, +) -> None: + """Test that default units are used on API error. + + Note: The program and the options used in this test aren't related. + """ + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + setattr( + client, + get_program_information_method, + AsyncMock(side_effect=HomeConnectError("error.key")), + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + service_data = { + "device_id": device_entry.id, + "affects_to": affects_to, + service_option: 35, + } + if program: + service_data["program"] = program + + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + service_data, + blocking=True, + ) + await hass.async_block_till_done() + + kwargs = getattr(client, method_call).call_args.kwargs + call_options: list[Option] = ( + kwargs.get("options") or kwargs["array_of_options"].options + ) + assert call_options[0].value == 35 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 5f615833700a0a..4866068315bee5 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -22,7 +22,7 @@ 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.""" diff --git a/tests/components/html5/conftest.py b/tests/components/html5/conftest.py index d22062bcfde94f..b818dbe6b7e383 100644 --- a/tests/components/html5/conftest.py +++ b/tests/components/html5/conftest.py @@ -26,6 +26,15 @@ ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.html5.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock ntfy configuration entry.""" diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index 6beb8509b29154..419d183d43f224 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -1,120 +1,114 @@ """Test the HTML5 config flow.""" -from unittest.mock import patch +import binascii +from unittest.mock import AsyncMock, patch import pytest -from homeassistant import config_entries, data_entry_flow from homeassistant.components.html5.const import ( ATTR_VAPID_EMAIL, - ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, DOMAIN, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from .conftest import MOCK_CONF, MOCK_CONF_PUB_KEY +from .conftest import ATTR_VAPID_PRV_KEY, MOCK_CONF, MOCK_CONF_PUB_KEY -async def test_step_user_success(hass: HomeAssistant) -> None: +async def test_step_user_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test a successful user config flow.""" - - with patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONF.copy(), - ) - - await hass.async_block_till_done() - - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == { - ATTR_VAPID_PRV_KEY: MOCK_CONF[ATTR_VAPID_PRV_KEY], - ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, - ATTR_VAPID_EMAIL: MOCK_CONF[ATTR_VAPID_EMAIL], - CONF_NAME: DOMAIN, - } - - assert mock_setup_entry.call_count == 1 - - -async def test_step_user_success_generate(hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONF + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **MOCK_CONF, + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + CONF_NAME: DOMAIN, + } + + assert mock_setup_entry.call_count == 1 + + +async def test_step_user_success_generate( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test a successful user config flow, generating a key pair.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - with patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - conf = {ATTR_VAPID_EMAIL: MOCK_CONF[ATTR_VAPID_EMAIL]} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - - await hass.async_block_till_done() - - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][ATTR_VAPID_EMAIL] == MOCK_CONF[ATTR_VAPID_EMAIL] - - assert mock_setup_entry.call_count == 1 - - -async def test_step_user_new_form(hass: HomeAssistant) -> None: - """Test new user input.""" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} with patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + "homeassistant.components.html5.config_flow.vapid_generate_private_key", + return_value=MOCK_CONF[ATTR_VAPID_PRV_KEY], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {ATTR_VAPID_EMAIL: "test@example.com"} ) + await hass.async_block_till_done() - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **MOCK_CONF, + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + CONF_NAME: DOMAIN, + } - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert mock_setup_entry.call_count == 0 - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MOCK_CONF - ) - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert mock_setup_entry.call_count == 1 + assert mock_setup_entry.call_count == 1 -@pytest.mark.parametrize( - ("key", "value"), - [ - (ATTR_VAPID_PRV_KEY, "invalid"), - ], -) +@pytest.mark.parametrize("exception", [ValueError, binascii.Error]) async def test_step_user_form_invalid_key( - hass: HomeAssistant, key: str, value: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, ) -> None: """Test invalid user input.""" - with patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - bad_conf = MOCK_CONF.copy() - bad_conf[key] = value - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=bad_conf - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - await hass.async_block_till_done() - - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert mock_setup_entry.call_count == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + with patch( + "homeassistant.components.html5.config_flow.vapid_get_public_key", + side_effect=exception, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONF ) - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert mock_setup_entry.call_count == 1 + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"vapid_prv_key": "invalid_prv_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONF + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **MOCK_CONF, + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + CONF_NAME: DOMAIN, + } + assert mock_setup_entry.call_count == 1 diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index de6ea29855ad54..4b9113f03780db 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -15,7 +15,7 @@ STEP_PLM_MANUALLY, ) from homeassistant.components.insteon.const import CONF_HUB_VERSION, DOMAIN -from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -108,19 +108,10 @@ async def test_form_select_modem(hass: HomeAssistant) -> None: async def test_fail_on_existing(hass: HomeAssistant) -> None: """Test we fail if the integration is already configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - config_entry.add_to_hass(hass) - assert config_entry.state is ConfigEntryState.NOT_LOADED + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - context={"source": config_entries.SOURCE_USER}, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 8d0ee4e901abf2..ea3d60a5be1b2d 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -215,6 +215,63 @@ def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> 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" diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index 51f5b4389a0a51..74bf8f992320d1 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -7,6 +7,7 @@ from pizone import Controller, ControllerCommandError, Zone import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -21,6 +22,11 @@ ClimateEntityFeature, HVACMode, ) +from homeassistant.components.izone.climate import ( + ATTR_AIRFLOW, + IZONE_SERVICE_AIRFLOW_MAX, + IZONE_SERVICE_AIRFLOW_MIN, +) from homeassistant.components.izone.const import DOMAIN from homeassistant.components.izone.coordinator import UPDATE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN @@ -623,3 +629,32 @@ async def test_command_connection_error_recovers_on_coordinator_refresh( assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "airflow"), + [ + pytest.param(IZONE_SERVICE_AIRFLOW_MIN, 41, id="min-int"), + pytest.param(IZONE_SERVICE_AIRFLOW_MAX, 41, id="max-int"), + pytest.param(IZONE_SERVICE_AIRFLOW_MIN, 40.9, id="min-float"), + pytest.param(IZONE_SERVICE_AIRFLOW_MAX, 40.9, id="max-float"), + ], +) +async def test_airflow_rejects_non_multiples_of_five( + hass: HomeAssistant, + mock_zones: list[Mock], + service: str, + airflow: float, +) -> None: + """Airflow services reject values that are not multiples of 5.""" + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_AIRFLOW: airflow}, + blocking=True, + ) + + mock_zones[0].set_airflow_min.assert_not_called() + mock_zones[0].set_airflow_max.assert_not_called() diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index 7c54d4ddc62d9b..beeb487a295c85 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -54,12 +54,7 @@ 'area_id': None, 'config_entry_id': , 'config_subentry_id': , - 'configuration_url': None, - 'connections': set({ - }), 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, 'id': , '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': , + 'parent_device_id': , }) # --- # name: test_state.3 @@ -164,12 +154,7 @@ 'area_id': None, 'config_entry_id': , 'config_subentry_id': , - 'configuration_url': None, - 'connections': set({ - }), 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, 'id': , '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': , + 'parent_device_id': , }) # --- # name: test_state.7 diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py index f980e39f652303..f1c11f6a16485b 100644 --- a/tests/components/kitchen_sink/test_sensor.py +++ b/tests/components/kitchen_sink/test_sensor.py @@ -9,6 +9,7 @@ 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 diff --git a/tests/components/kitchen_sink/test_switch.py b/tests/components/kitchen_sink/test_switch.py index 17f4b9db6ebde3..79b228e706f8a4 100644 --- a/tests/components/kitchen_sink/test_switch.py +++ b/tests/components/kitchen_sink/test_switch.py @@ -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 diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index ad0dbb76cb85be..b78ffb5628df0c 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -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: diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py index 5e03fbf5f193dc..88734ded6c6644 100644 --- a/tests/components/persistent_notification/test_trigger.py +++ b/tests/components/persistent_notification/test_trigger.py @@ -100,3 +100,17 @@ def trigger_callback_id( assert result["notification"]["notification_id"] == "42" assert result["notification"]["message"] == "Forty Two" assert result_any[2] == result_id[0] + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "42", "message": "Is the answer to the ultimate question"}, + blocking=True, + ) + + result = result_any[3].get("trigger") + assert result["platform"] == "persistent_notification" + assert result["update_type"] == pn.UpdateType.UPDATED + assert result["notification"]["notification_id"] == "42" + assert result["notification"]["message"] == "Is the answer to the ultimate question" + assert result_any[3] == result_id[1] diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f71dc5f106b835..a005290a8c0b42 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -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, diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 5f8988c689c378..daa6396e35bec2 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1219,3 +1219,143 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: } 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"}, + } diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index fda021051d82f8..5aa95fcbfc373c 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -43,14 +43,19 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: async def test_user_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_technove: MagicMock, ) -> None: """Test we abort the config flow if TechnoVE station is already configured.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} ) assert result.get("type") is FlowResultType.ABORT @@ -63,7 +68,13 @@ async def test_connection_error(hass: HomeAssistant, mock_technove: MagicMock) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.com"}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "example.com"} ) assert result.get("type") is FlowResultType.FORM @@ -203,12 +214,18 @@ async def test_zeroconf_connection_error( async def test_user_station_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test we abort zeroconf flow if TechnoVE station already configured.""" + """Test we abort user flow if TechnoVE station already configured.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} ) assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 34df3f31b91622..9dd312fc3a5e5c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,12 +1,13 @@ """The tests for the TTS component.""" import asyncio +from collections.abc import AsyncGenerator from http import HTTPStatus import io from pathlib import Path import tempfile from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import wave from freezegun.api import FrozenDateTimeFactory @@ -1856,6 +1857,79 @@ async def bad_data_gen(): pass +async def _audio_data_gen() -> AsyncGenerator[bytes]: + """Yield test audio data.""" + yield b"audio" + + +@pytest.mark.parametrize( + ("from_extension", "audio_input", "expected_input"), + [ + pytest.param( + "wav", + _audio_data_gen(), + ["-f", "wav", "-probesize", "32", "-i", "pipe:0"], + id="streaming_wav", + ), + pytest.param( + "wav", + Path("input.wav"), + ["-f", "wav", "-i", "input.wav"], + id="static_wav", + ), + pytest.param( + "mp3", + _audio_data_gen(), + ["-f", "mp3", "-i", "pipe:0"], + id="streaming_mp3", + ), + ], +) +async def test_async_convert_audio_probe_size( + hass: HomeAssistant, + from_extension: str, + audio_input: AsyncGenerator[bytes] | Path, + expected_input: list[str], +) -> None: + """Test probe size is limited for streaming WAV conversion only.""" + assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + + mock_process = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout.read = AsyncMock(return_value=b"") + mock_process.wait = AsyncMock(return_value=0) + + with patch( + "asyncio.create_subprocess_exec", return_value=mock_process + ) as mock_create_subprocess_exec: + async for _chunk in tts._async_convert_audio( + hass, + from_extension, + audio_input, + "flac", + to_sample_rate=48000, + to_sample_channels=1, + to_sample_bytes=2, + ): + pass + + command = list(mock_create_subprocess_exec.call_args.args) + input_index = command.index("-i") + # FFmpeg input options are positional. + assert command[4 : input_index + 2] == expected_input + assert command[input_index + 2 :] == [ + "-f", + "flac", + "-ar", + "48000", + "-ac", + "1", + "-sample_fmt", + "s16", + "pipe:1", + ] + + async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 56fd48c43f0cb4..9605507418b93f 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -4,8 +4,15 @@ from unittest.mock import AsyncMock, patch import pytest -from vizaio import AppConfig, InputInfo, SettingInfo, SettingType, VizioConnectionError -from vizaio.profiles import SOUNDBAR_PROFILE +from vizaio import ( + AppConfig, + InputInfo, + SettingInfo, + SettingType, + VizioConnectionError, + VizioNotFoundError, +) +from vizaio.profiles import SOUNDBAR_PROFILE, TV_KEYS, TV_PROFILE from homeassistant.components.vizio.const import DOMAIN from homeassistant.core import HomeAssistant @@ -26,6 +33,7 @@ UNIQUE_ID, VERSION, audio_setting, + state_extended, ) from tests.common import MockConfigEntry @@ -196,6 +204,10 @@ def vizio_bypass_setup_fixture() -> Generator[None]: def vizio_bypass_update_fixture() -> Generator[None]: """Mock component update with minimal data.""" with ( + patch( + "homeassistant.components.vizio.Vizio.get_state_extended", + side_effect=VizioNotFoundError("not supported"), + ), patch( "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, @@ -228,6 +240,46 @@ def vizio_bypass_update_fixture() -> Generator[None]: yield +@pytest.fixture(name="mock_vizio") +def mock_vizio_fixture() -> Generator[AsyncMock]: + """Mock the Vizio device the integration talks to. + + Yields the device instance the coordinator receives, preloaded with a + modern TV firmware response set. Tests override individual return values + or side effects instead of stacking per-method patches. + """ + with patch( + "homeassistant.components.vizio.Vizio", autospec=True + ) as mock_vizio_class: + device = mock_vizio_class.return_value + # Properties on the real class; autospec leaves them as bare mocks + device.profile = TV_PROFILE + device.available_keys = TV_KEYS + device.get_state_extended.return_value = state_extended( + current_input=CURRENT_INPUT + ) + device.get_power_state.return_value = True + device.get_current_input.return_value = CURRENT_INPUT + device.get_current_app_config.return_value = None + device.get_inputs.return_value = get_mock_inputs(INPUT_LIST) + device.get_settings.return_value = { + "volume": audio_setting("volume", int(TV_PROFILE.max_volume / 2)), + "eq": audio_setting("eq", CURRENT_EQ), + "mute": audio_setting("mute", "Off"), + } + device.get_setting.return_value = SettingInfo( + setting_type="audio", + name="eq", + value=CURRENT_EQ, + hashval=0, + type=SettingType.LIST, + options=tuple(EQ_LIST), + ) + device.get_model_name.return_value = MODEL + device.get_version.return_value = VERSION + yield device + + @pytest.fixture(name="vizio_guess_device_type") def vizio_guess_device_type_fixture() -> Generator[None]: """Mock vizio device type probe to report a speaker.""" @@ -260,6 +312,10 @@ def vizio_cant_connect_fixture() -> Generator[None]: "homeassistant.components.vizio.config_flow.Vizio.ping_auth", side_effect=VizioConnectionError("cannot connect"), ), + patch( + "homeassistant.components.vizio.Vizio.get_state_extended", + side_effect=VizioConnectionError("cannot connect"), + ), patch( "homeassistant.components.vizio.Vizio.get_power_state", side_effect=VizioConnectionError("cannot connect"), @@ -280,6 +336,10 @@ def vizio_cant_connect_fixture() -> Generator[None]: def vizio_update_fixture() -> Generator[None]: """Mock valid updates to vizio device.""" with ( + patch( + "homeassistant.components.vizio.Vizio.get_state_extended", + side_effect=VizioNotFoundError("not supported"), + ), patch( "homeassistant.components.vizio.Vizio.get_settings", return_value={ diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index c4dd57e5a980a5..81fdb5f849ad67 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,7 +2,14 @@ from ipaddress import ip_address -from vizaio import AppConfig, AppRecord, PairChallenge, SettingInfo, SettingType +from vizaio import ( + AppConfig, + AppRecord, + PairChallenge, + SettingInfo, + SettingType, + StateExtended, +) from vizaio.profiles import SOUNDBAR_PROFILE, TV_PROFILE from homeassistant.components.media_player import ( @@ -66,6 +73,26 @@ def audio_setting( ) +def state_extended( + *, + power_on: bool = True, + current_input: str = "HDMI", + current_app: AppConfig | None = None, +) -> StateExtended: + """Build a StateExtended payload for mock device responses.""" + return StateExtended( + power_on=power_on, + power_mode="Eco Mode" if not power_on else "Quick Start", + current_input=current_input, + current_input_hashval=None, + current_app=current_app, + screen_mode="Full screen", + media_state="MediaState::Stopped", + device_name=NAME, + raw={}, + ) + + CURRENT_EQ = "Music" EQ_LIST = ["Music", "Movie"] diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index 541da88f4480d2..3c71d2f9b45bca 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from vizaio import VizioConnectionError +from vizaio import VizioConnectionError, VizioNotFoundError from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, @@ -26,6 +26,8 @@ CONF_HOST, CONF_INCLUDE, CONF_NAME, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -36,6 +38,8 @@ ADDITIONAL_APP_CONFIG, APP_RECORDS, CURRENT_APP, + CURRENT_INPUT, + ENTITY_ID, HOST, HOST2, MOCK_USER_VALID_TV_CONFIG, @@ -45,6 +49,7 @@ UNIQUE_ID, VERSION, VOLUME_STEP, + state_extended, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -196,6 +201,77 @@ async def test_device_registry_without_model_or_version( assert device.manufacturer == "VIZIO" +@pytest.mark.usefixtures("vizio_connect") +async def test_state_extended_polling( + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + mock_vizio: AsyncMock, +) -> None: + """Test modern firmware polls via a single state_extended call.""" + await setup_integration(hass, mock_tv_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes["source"] == CURRENT_INPUT + # The bundled endpoint replaces the individual state getters + mock_vizio.get_power_state.assert_not_called() + mock_vizio.get_current_input.assert_not_called() + mock_vizio.get_current_app_config.assert_not_called() + + +@pytest.mark.usefixtures("vizio_connect") +async def test_state_extended_power_off( + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + mock_vizio: AsyncMock, +) -> None: + """Test state_extended reporting the device as off.""" + mock_vizio.get_state_extended.return_value = state_extended(power_on=False) + + await setup_integration(hass, mock_tv_config_entry) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_vizio.get_settings.assert_not_called() + + +@pytest.mark.usefixtures("vizio_connect") +async def test_state_extended_probed_only_once( + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + mock_vizio: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test firmware without state_extended is not re-probed every refresh.""" + mock_vizio.get_state_extended.side_effect = VizioNotFoundError("not supported") + + await setup_integration(hass, mock_tv_config_entry) + mock_vizio.get_state_extended.reset_mock() + + for _ in range(3): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_vizio.get_state_extended.assert_not_called() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + +@pytest.mark.usefixtures("vizio_connect") +async def test_state_extended_connection_error( + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + mock_vizio: AsyncMock, +) -> None: + """Test a state_extended connection error fails the update.""" + mock_vizio.get_state_extended.side_effect = VizioConnectionError("cannot connect") + + mock_tv_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_tv_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_tv_config_entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_portless_host_is_resolved_and_persisted(hass: HomeAssistant) -> None: """Test a config entry storing a host without a port is repaired on setup.""" diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index fb59ac08deb43d..c46e05baf86306 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -64,8 +64,9 @@ 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"}) diff --git a/tests/e2e/package.json b/tests/e2e/package.json index bb69b43f05bd7a..0e2d348a9ac17a 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@11.13.0", + "packageManager": "pnpm@11.21.0", "scripts": { "test": "playwright test" }, diff --git a/tests/helpers/template/extensions/test_areas.py b/tests/helpers/template/extensions/test_areas.py index 413586148422e6..41ac4c6c214feb 100644 --- a/tests/helpers/template/extensions/test_areas.py +++ b/tests/helpers/template/extensions/test_areas.py @@ -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]) diff --git a/tests/helpers/template/extensions/test_devices.py b/tests/helpers/template/extensions/test_devices.py index aba4f7b69fabc1..3c797b5ad1820a 100644 --- a/tests/helpers/template/extensions/test_devices.py +++ b/tests/helpers/template/extensions/test_devices.py @@ -325,3 +325,41 @@ async def test_device_attr( ) assert_result_info(info, [device_entry.id]) assert info.rate_limit is None + + +async def test_device_functions_with_child_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device functions with child devices.""" + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + 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.id, + name="Outlet 1", + ) + + # device_id finds a child device by name + info = render_to_info(hass, "{{ device_id('Outlet 1') }}") + assert_result_info(info, child_device.id) + + # device_name resolves a child device + info = render_to_info(hass, f"{{{{ device_name('{child_device.id}') }}}}") + assert_result_info(info, "Outlet 1") + + # device_attr returns None for attributes a child device does not have + info = render_to_info( + hass, f"{{{{ device_attr('{child_device.id}', 'manufacturer') }}}}" + ) + assert_result_info(info, None) + info = render_to_info( + hass, f"{{{{ device_attr('{child_device.id}', 'parent_device_id') }}}}" + ) + assert_result_info(info, child_device.parent_device_id) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 65afbe3ba23368..286e2e73321854 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,6 +2,7 @@ from collections.abc import Callable, Generator, Iterable from contextlib import AbstractContextManager, nullcontext +from copy import deepcopy from datetime import datetime, timedelta from functools import partial import json @@ -373,6 +374,22 @@ async def test_loading_from_storage( "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, "data": { + "child_devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "created_at": created_at, + "disabled_by": "device", + "id": "childdeviceid", + "identifiers": [["test", "strip_outlet_1"]], + "labels": [], + "modified_at": modified_at, + "name_by_user": None, + "name": "Outlet 1", + "parent_device_id": "abcdefghijklm", + } + ], "devices": [ { "area_id": "12345A", @@ -435,6 +452,13 @@ async def test_loading_from_storage( assert len(registry.devices) == 1 assert len(registry.deleted_devices) == 1 + # A stored child device is loaded, with disabled_by "device" restored to the enum + loaded_child = registry.async_get("childdeviceid", include_main_devices=False) + assert loaded_child is not None + assert loaded_child.parent_device_id == "abcdefghijklm" + assert loaded_child.disabled_by is dr.DeviceEntryDisabler.DEVICE + assert loaded_child.identifiers == {("test", "strip_outlet_1")} + assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", config_entry_id=mock_config_entry.entry_id, @@ -596,6 +620,7 @@ async def test_migration_from_1_1( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -758,6 +783,7 @@ async def test_migration_from_1_2( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -903,6 +929,7 @@ async def test_migration_fom_1_3( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1050,6 +1077,7 @@ async def test_migration_from_1_4( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1199,6 +1227,7 @@ async def test_migration_from_1_5( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1350,6 +1379,7 @@ async def test_migration_from_1_6( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1503,6 +1533,7 @@ async def test_migration_from_1_7( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1657,6 +1688,7 @@ async def test_migration_from_1_10( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -1799,6 +1831,7 @@ async def test_migration_from_1_11( "minor_version": dr.STORAGE_VERSION_MINOR, "key": dr.STORAGE_KEY, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -7275,6 +7308,7 @@ async def test_loading_invalid_configuration_url_from_storage( "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -8403,6 +8437,7 @@ def _stored_device( "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, "data": { + "child_devices": [], "devices": [ _stored_device( "old", @@ -9589,3 +9624,2499 @@ async def test_dict_repr_dual_writes_deprecated_keys( assert "composite_primary_config_entry" not in repr_ assert "split_at" not in repr_ assert "has_composite_identifiers" not in repr_ + + +def _create_parent_and_child( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + config_subentry_id: str | UndefinedType = UNDEFINED, +) -> tuple[dr.DeviceEntry, dr.ChildDeviceEntry]: + """Create a parent device with one child device.""" + parent = device_registry.async_get_or_create( + config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + return parent, child_device + + +async def test_child_device_create( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a child device.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert isinstance(child_device, dr.ChildDeviceEntry) + assert child_device.parent_device_id == parent.id + assert child_device.config_entry_id == mock_config_entry.entry_id + assert child_device.config_subentry_id is None + assert child_device.identifiers == {("test", "strip_outlet_1")} + assert child_device.name == "Outlet 1" + assert child_device.area_id is None + assert child_device.disabled_by is None + + assert device_registry.async_get(child_device.id) is child_device + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) + is child_device + ) + assert len(device_registry.devices) == 1 + assert len(device_registry.child_devices) == 1 + assert dr.async_entries_for_parent_device(device_registry, parent.id) == [ + child_device + ] + assert dr.async_child_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) == [child_device] + assert dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) == [parent] + + await hass.async_block_till_done() + assert [event.data for event in update_events] == [ + {"action": "create", "device_id": parent.id}, + {"action": "create", "device_id": child_device.id}, + ] + + assert child_device.dict_repr == { + "area_id": None, + "config_entry_id": mock_config_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( + ("attr_name", "expected_default"), + [ + ("configuration_url", None), + ("connections", set()), + ("entry_type", None), + ("hw_version", None), + ("manufacturer", None), + ("model", None), + ("model_id", None), + ("serial_number", None), + ("sw_version", None), + ("via_device_id", None), + ], +) +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(AttributeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(AttributeError), + 0, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_child_device_deprecated_device_entry_attrs( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + attr_name: str, + expected_default: Any, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test accessing a DeviceEntry-only attribute on a child device. + + Custom integrations get the DeviceEntry default value and a deprecation warning; + core and core integrations raise AttributeError so the attribute reads as missing. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + what = ( + f"accesses ChildDeviceEntry.{attr_name}, which does not exist on child devices" + ) + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + assert getattr(child_device, attr_name) == expected_default + assert caplog.text.count(what) == expected_log + + +@pytest.mark.parametrize("attr_name", sorted(dr._CHILD_DEVICE_COMPAT_ATTRS)) +@pytest.mark.usefixtures("hass") +async def test_child_device_deprecated_attrs_missing_for_core( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + attr_name: str, +) -> None: + """Test DeviceEntry-only attributes read as missing without an integration frame. + + This is the template/pure-core path: hasattr must be False so device_attr and + is_device_attr fall back to None instead of raising. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert hasattr(child_device, attr_name) is False + with pytest.raises(AttributeError, match=f"has no attribute '{attr_name}'"): + getattr(child_device, attr_name) + + +@pytest.mark.usefixtures("hass") +async def test_child_device_unknown_attribute_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test accessing a genuinely unknown attribute on a child device raises.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises(AttributeError, match="has no attribute 'does_not_exist'"): + getattr(child_device, "does_not_exist") # noqa: B009 + + +@pytest.mark.usefixtures("hass") +async def test_async_get_exclude_child_devices( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_get with include_child_devices=False treats children as absent.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert device_registry.async_get(child_device.id) is child_device + assert device_registry.async_get(parent.id, include_child_devices=False) is parent + assert ( + device_registry.async_get(child_device.id, include_child_devices=False) is None + ) + + +@pytest.mark.usefixtures("hass") +async def test_async_get_child_device_by_identifier( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test looking up a child device by identifier.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "strip_outlet_1"), mock_config_entry.entry_id + ) + is child_device + ) + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "unknown"), mock_config_entry.entry_id + ) + is None + ) + # Only child devices are searched, so a main device's identifier is not found + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "strip"), mock_config_entry.entry_id + ) + is None + ) + + +@pytest.mark.usefixtures("hass") +async def test_child_device_create_idempotent( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test re-registering a child device is idempotent and applies updates.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + child_device_2 = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet one", + ) + assert child_device_2.id == child_device.id + assert child_device_2.name == "Outlet one" + assert len(device_registry.child_devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_child_device_create_with_suggested_area( + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test suggested_area sets the initial area of a new child device only.""" + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + suggested_area="Garden", + ) + garden = area_registry.async_get_area_by_name("Garden") + assert garden is not None + assert child_device.area_id == garden.id + + # suggested_area is a one-shot hint for a new child device + child_device_2 = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + suggested_area="Garage", + ) + assert child_device_2.area_id == garden.id + + +@pytest.mark.parametrize( + ("identifiers", "parent_key", "error"), + [ + pytest.param( + {("test", "strip_outlet_1")}, + "unknown", + "must be created before its child devices", + id="unknown_parent", + ), + pytest.param( + {("test", "grandchild")}, + "child", + "can't be the parent of another child device", + id="parent_is_child", + ), + pytest.param( + {("test", "strip_outlet_1")}, + "other_strip", + "reparenting is not supported", + id="reparent", + ), + ], +) +@pytest.mark.usefixtures("hass") +async def test_child_device_create_errors( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + identifiers: set[tuple[str, str]], + parent_key: str, + error: str, +) -> None: + """Test invalid child device registrations raise DeviceInfoError.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + other_strip = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "other_strip")}, + name="Other strip", + ) + parent_device_ids = { + "unknown": "nonexistent-device-id", + "child": child_device.id, + "other_strip": other_strip.id, + } + + with pytest.raises(dr.DeviceInfoError, match=error): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers=identifiers, + parent_device_id=parent_device_ids[parent_key], + name="Nope", + ) + + # Validation precedes mutation, so the registry is unchanged by the rejection + assert len(device_registry.devices) == 2 + assert len(device_registry.child_devices) == 1 + unchanged_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert unchanged_child is not None + assert unchanged_child.parent_device_id == parent.id + + +async def test_child_device_parent_in_other_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child device must share its parent's config entry.""" + other_entry = MockConfigEntry(title=None) + other_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + + with pytest.raises(dr.DeviceInfoError, match="same config entry"): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + + # Validation precedes mutation, so no child device is created + assert not device_registry.child_devices + assert len(device_registry.devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_child_device_subentry( + device_registry: dr.DeviceRegistry, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test a child device lives in the same config subentry as its parent.""" + entry_id = mock_config_entry_with_subentries.entry_id + parent, child_device = _create_parent_and_child( + device_registry, entry_id, config_subentry_id="mock-subentry-id-1-1" + ) + assert child_device.config_subentry_id == "mock-subentry-id-1-1" + assert child_device.parent_device_id == parent.id + + # A child device in a different subentry than its parent is rejected + with pytest.raises(dr.DeviceInfoError, match="same config subentry"): + device_registry.async_get_or_create_child( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-2", + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + with pytest.raises(dr.DeviceInfoError, match="same config subentry"): + device_registry.async_get_or_create_child( + config_entry_id=entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + + # Neither rejected registration created a child device + assert len(device_registry.child_devices) == 1 + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "strip_outlet_2"), entry_id + ) + is None + ) + + +async def test_child_device_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating a child device.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + updated = device_registry.async_update_child_device( + child_device.id, + area_id="garden", + labels={"outdoor"}, + name_by_user="Garden lamp plug", + ) + assert isinstance(updated, dr.ChildDeviceEntry) + assert updated.area_id == "garden" + assert updated.labels == {"outdoor"} + assert updated.name_by_user == "Garden lamp plug" + + # Clearing the area restores inheriting the parent's area + updated = device_registry.async_update_child_device(child_device.id, area_id=None) + assert updated.area_id is None + + await hass.async_block_till_done() + assert [event.data for event in update_events] == [ + { + "action": "update", + "device_id": child_device.id, + "changes": {"area_id": None, "labels": set(), "name_by_user": None}, + }, + { + "action": "update", + "device_id": child_device.id, + "changes": {"area_id": "garden"}, + }, + ] + + +@pytest.mark.usefixtures("hass") +async def test_child_device_update_identifiers( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting child device identifiers with new_identifiers, incl. collisions.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + # new_identifiers replaces the child device's identifiers + updated = device_registry.async_update_child_device( + child_device.id, + new_identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")}, + ) + assert updated.identifiers == { + ("test", "strip_outlet_1"), + ("test", "strip_outlet_1_alias"), + } + + updated = device_registry.async_update_child_device( + child_device.id, new_identifiers={("test", "strip_outlet_1")} + ) + assert updated.identifiers == {("test", "strip_outlet_1")} + + with pytest.raises(HomeAssistantError, match="must have at least one identifier"): + device_registry.async_update_child_device( + child_device.id, new_identifiers=set() + ) + + # A child device can't take an identifier registered by a device (its parent) + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_child_device( + child_device.id, new_identifiers={("test", "strip")} + ) + + # A device can't take an identifier registered by a child device + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + parent.id, merge_identifiers={("test", "strip_outlet_1")} + ) + + +@pytest.mark.usefixtures("hass") +async def test_child_device_get_or_create_rejects_invalid_identifier_count( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a child device with zero or multiple identifiers is rejected.""" + parent, _child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises(dr.DeviceInfoError, match="must have at least one identifier"): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers=set(), + parent_device_id=parent.id, + name="Outlet 1", + ) + # The rejected registration leaves the existing child unchanged + assert len(device_registry.child_devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_child_device_get_or_create_merges_identifiers( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test re-registering a child device merges additional identifiers into it. + + merge_identifiers is rejected on the public update path for a child device, so + the internal merge only happens through async_get_or_create. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + merged = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert merged.id == child_device.id + assert merged.identifiers == { + ("test", "strip_outlet_1"), + ("test", "strip_outlet_1_alias"), + } + assert len(device_registry.child_devices) == 1 + + +async def test_remove_parent_cascades_to_children( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a parent device removes its child devices first.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + remove_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + device_registry.async_remove_device(parent.id) + + assert device_registry.async_get(parent.id) is None + assert device_registry.async_get(child_device.id) is None + assert not device_registry.child_devices + assert child_device.id in device_registry.deleted_devices + assert parent.id in device_registry.deleted_devices + + await hass.async_block_till_done() + assert [event.data for event in remove_events] == [ + { + "action": "remove", + "device_id": child_device.id, + "device": child_device.dict_repr, + }, + {"action": "remove", "device_id": parent.id, "device": parent.dict_repr}, + ] + + +@pytest.mark.usefixtures("hass") +async def test_remove_child_device_and_restore( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing and restoring a child device preserves id and user data.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_child_device( + child_device.id, area_id="garden", labels={"outdoor"}, name_by_user="Lamp" + ) + + device_registry.async_remove_device(child_device.id) + assert device_registry.async_get(child_device.id) is None + assert device_registry.async_get(parent.id) is not None + + restored = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert restored.id == child_device.id + assert restored.area_id == "garden" + assert restored.labels == {"outdoor"} + assert restored.name_by_user == "Lamp" + assert restored.parent_device_id == parent.id + + +@pytest.mark.usefixtures("hass") +async def test_parent_disable_cascades_to_children( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test disabling and enabling a parent device cascades to its children.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_child is not None + assert updated_child.disabled_by is dr.DeviceEntryDisabler.DEVICE + + device_registry.async_update_device(parent.id, disabled_by=None) + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_child is not None + assert updated_child.disabled_by is None + + +@pytest.mark.usefixtures("hass") +async def test_parent_enable_keeps_user_disabled_child( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test enabling a parent does not enable a user-disabled child device.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_child_device( + child_device.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device_registry.async_update_device(parent.id, disabled_by=None) + + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_child is not None + assert updated_child.disabled_by is dr.DeviceEntryDisabler.USER + + +@pytest.mark.usefixtures("hass") +async def test_child_device_disabled_by_reconciled_with_parent( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a child device's disabled_by is reconciled with the parent state.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + # DEVICE on a child of an enabled parent is inconsistent and ignored + updated = device_registry.async_update_child_device( + child_device.id, disabled_by=dr.DeviceEntryDisabler.DEVICE + ) + assert updated.disabled_by is None + assert "whose parent device is enabled" in caplog.text + + # A child of a disabled parent can't be enabled; it stays disabled by the parent + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + updated = device_registry.async_update_child_device( + child_device.id, disabled_by=None + ) + assert updated.disabled_by is dr.DeviceEntryDisabler.DEVICE + assert "whose parent device is disabled" in caplog.text + + # A child device created under a disabled parent is born disabled + new_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + assert new_child.disabled_by is dr.DeviceEntryDisabler.DEVICE + + +async def test_config_entry_reenable_with_user_disabled_parent_no_warning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test re-enabling a config entry with a user-disabled parent does not warn. + + The config-entry re-enable cascade clears the child's CONFIG_ENTRY disable by passing + disabled_by=None; with the parent still user-disabled the child is coerced back to + DEVICE. This internal reconciliation must not emit the "sets disabled_by to None" + report, while a direct external enable of such a child must. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + disabled_parent = device_registry.async_get(parent.id) + disabled_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert disabled_parent is not None + assert disabled_child is not None + assert disabled_parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + assert disabled_child.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + # User disables the parent while the config entry is disabled; the child keeps its + # CONFIG_ENTRY disable because it is already disabled + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + child_after_parent_disable = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert child_after_parent_disable is not None + assert child_after_parent_disable.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + caplog.clear() + await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None) + await hass.async_block_till_done() + updated_parent = device_registry.async_get(parent.id) + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_parent is not None + assert updated_child is not None + assert updated_parent.disabled_by is dr.DeviceEntryDisabler.USER + assert updated_child.disabled_by is dr.DeviceEntryDisabler.DEVICE + assert "Detected code that" not in caplog.text + + # A direct external enable of the same child still warns, as it is not the cascade + caplog.clear() + updated = device_registry.async_update_child_device( + child_device.id, disabled_by=None + ) + assert updated.disabled_by is dr.DeviceEntryDisabler.DEVICE + assert "whose parent device is disabled" in caplog.text + + +async def test_config_entry_disable_with_children( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test disabling and enabling a config entry cascades to child devices.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + updated_parent = device_registry.async_get(parent.id) + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_parent is not None + assert updated_child is not None + assert updated_parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + assert updated_child.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None) + await hass.async_block_till_done() + updated_parent = device_registry.async_get(parent.id) + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_parent is not None + assert updated_child is not None + assert updated_parent.disabled_by is None + assert updated_child.disabled_by is None + + +async def test_disable_child_device_directly_disables_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test disabling a child device directly disables and re-enables its entities.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + "test", + "outlet_1", + config_entry=mock_config_entry, + device_id=child_device.id, + ) + + device_registry.async_update_child_device( + child_device.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + await hass.async_block_till_done() + + updated_entity = entity_registry.async_get(entity_entry.entity_id) + assert updated_entity is not None + assert updated_entity.disabled_by is er.RegistryEntryDisabler.DEVICE + + device_registry.async_update_child_device(child_device.id, disabled_by=None) + await hass.async_block_till_done() + + updated_entity = entity_registry.async_get(entity_entry.entity_id) + assert updated_entity is not None + assert updated_entity.disabled_by is None + + +async def test_move_parent_with_children_rejected( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a parent device with child devices can't move.""" + other_entry = MockConfigEntry(title=None) + other_entry.add_to_hass(hass) + parent, _ = _create_parent_and_child(device_registry, mock_config_entry.entry_id) + + with pytest.raises(HomeAssistantError, match="has child devices"): + device_registry.async_update_device( + parent.id, new_config_entry_id=other_entry.entry_id + ) + + # The rejected move leaves the parent and its child untouched + unchanged_parent = device_registry.async_get(parent.id) + assert unchanged_parent is not None + assert unchanged_parent.config_entry_id == mock_config_entry.entry_id + assert len(device_registry.child_devices) == 1 + + +async def test_convert_device_to_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test converting an already-split device to a child device keeps its id.""" + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + old_split = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + identifiers={("test", "strip_outlet_1")}, + manufacturer="acme", + name="Outlet 1", + via_device_id=parent.id, + ) + device_registry.async_update_device( + old_split.id, area_id="garden", name_by_user="Lamp" + ) + # A new setup session: the split device is no longer live, so the integration + # can now adopt it as a child device. + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + converted = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert isinstance(converted, dr.ChildDeviceEntry) + assert converted.id == old_split.id + assert converted.parent_device_id == parent.id + assert converted.area_id == "garden" + assert converted.name_by_user == "Lamp" + assert converted.identifiers == {("test", "strip_outlet_1")} + assert len(device_registry.devices) == 1 + assert len(device_registry.child_devices) == 1 + + await hass.async_block_till_done() + assert [event.data for event in update_events] == [ + { + "action": "update", + "device_id": old_split.id, + "changes": { + "connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + "manufacturer": "acme", + "parent_device_id": None, + "via_device_id": parent.id, + }, + }, + ] + + +@pytest.mark.parametrize( + ("identifiers", "error"), + [ + pytest.param( + {("test", "other")}, + "can't be its own parent", + id="self_parent", + ), + pytest.param( + {("test", "strip")}, + "has child devices itself", + id="has_children", + ), + ], +) +@pytest.mark.usefixtures("hass") +async def test_convert_device_to_child_device_errors( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + identifiers: set[tuple[str, str]], + error: str, +) -> None: + """Test invalid device to child device conversions.""" + _create_parent_and_child(device_registry, mock_config_entry.entry_id) + other = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "other")}, + name="Other", + ) + + with pytest.raises(dr.DeviceInfoError, match=error): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers=identifiers, + parent_device_id=other.id, + name="Nope", + ) + + # The conversion guards run before any reconcile, so the rejection mutates nothing + assert len(device_registry.devices) == 2 + assert len(device_registry.child_devices) == 1 + assert device_registry.async_get(other.id) is other + + +@pytest.mark.parametrize( + "identifiers", + [ + pytest.param({("test", "strip_outlet_1")}, id="exact_identifiers"), + pytest.param( + {("test", "strip_outlet_1"), ("test", "strip_outlet_1_alias")}, + id="extra_identifiers", + ), + ], +) +@pytest.mark.usefixtures("hass") +async def test_link_device_info_matching_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + identifiers: set[tuple[str, str]], +) -> None: + """Test a bare-identifier device info matching a child device raises.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers=identifiers, + ) + + # The child device is left untouched: not converted, no new device created + assert len(device_registry.devices) == 1 + assert len(device_registry.child_devices) == 1 + assert device_registry.child_devices[child_device.id] == child_device + assert child_device.identifiers == {("test", "strip_outlet_1")} + + +@pytest.mark.usefixtures("hass") +async def test_convert_device_to_child_detaches_via_links( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test converting a device to a child detaches inbound via_device links.""" + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + outlet = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "outlet")}, + name="Outlet", + via_device_id=parent.id, + ) + nested = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "nested")}, + name="Nested", + via_device_id=outlet.id, + ) + assert nested.via_device_id == outlet.id + # A new setup session: the outlet is no longer live and can be adopted as a child + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + + converted = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "outlet")}, + parent_device_id=parent.id, + name="Outlet", + ) + assert isinstance(converted, dr.ChildDeviceEntry) + assert converted.id == outlet.id + + # The inbound via link is detached so it can no longer resolve to a child device + nested_after = device_registry.async_get_device(identifiers={("test", "nested")}) + assert nested_after is not None + assert nested_after.via_device_id is None + # No live device links to a child device through via_device_id + child_via_targets = [ + device.id + for device in device_registry.devices.values() + if device.via_device_id is not None + and device_registry.async_get(device.via_device_id, include_main_devices=False) + is not None + ] + assert child_via_targets == [] + + +@pytest.mark.usefixtures("hass") +async def test_convert_device_to_child_same_session_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test registering a live device's identifiers as a child raises.""" + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + name="Outlet 1", + ) + + with pytest.raises( + dr.DeviceInfoError, + match="registered as a device and as a child device", + ): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + + # The conversion guard runs before any reconcile, so the rejection leaves the + # device a main device and creates no child device + assert device_registry.async_get(device.id) is device + assert not device_registry.child_devices + assert len(device_registry.devices) == 2 + + +@pytest.mark.usefixtures("hass") +async def test_primary_device_info_matching_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a primary device info whose identifiers belong to a child raises.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + manufacturer="acme", + name="Outlet 1", + ) + + # The rejection leaves the child device untouched and creates no main device for + # its identifiers + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) + is child_device + ) + assert len(device_registry.child_devices) == 1 + assert len(device_registry.devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_get_or_create_via_device_id_naming_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a via_device_id resolving to a child device is rejected before mutation.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises( + dr.DeviceInfoError, + match="is a child device, which can't be a via device", + ): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "new_device")}, + via_device_id=child_device.id, + ) + + # Validation precedes mutation, so the rejected device is not partially created + assert ( + device_registry.async_get_device(identifiers={("test", "new_device")}) is None + ) + assert len(device_registry.devices) == 1 + + # The deprecated via_device (identifier form) resolves against main devices only, so + # a child's identifier is treated as an unknown via device: it logs a deprecation and + # links nothing rather than raising. Only the id form enforces the invariant. + linked = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "new_device")}, + via_device=("test", "strip_outlet_1"), + ) + assert linked.via_device_id is None + + +@pytest.mark.usefixtures("hass") +async def test_update_device_via_device_id_naming_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating via_device_id to a child device raises, leaving it unchanged.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "device")}, + name="Device", + ) + + with pytest.raises( + HomeAssistantError, + match="is a child device, which can't be a via device", + ): + device_registry.async_update_device(device.id, via_device_id=child_device.id) + + assert device_registry.async_get(device.id).via_device_id is None + + +@pytest.mark.usefixtures("hass") +async def test_deleted_device_restored_as_child_device( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a deleted device can restore as a child device and vice versa.""" + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + name="Outlet 1", + manufacturer="acme", + ) + device_registry.async_update_device(device.id, area_id="garden") + device_registry.async_remove_device(device.id) + + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + restored_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert isinstance(restored_child, dr.ChildDeviceEntry) + assert restored_child.id == device.id + assert restored_child.area_id == "garden" + + # And a deleted child device can restore as a device + device_registry.async_remove_device(restored_child.id) + restored_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + name="Outlet 1", + manufacturer="acme", + ) + assert isinstance(restored_device, dr.DeviceEntry) + assert restored_device.id == device.id + assert restored_device.area_id == "garden" + + +async def test_child_device_orphan_restore( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child device orphaned by config entry removal restores.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_child_device(child_device.id, area_id="garden") + + device_registry.async_clear_config_entry(mock_config_entry.entry_id) + assert not device_registry.devices + assert not device_registry.child_devices + + new_entry = MockConfigEntry(title=None) + new_entry.add_to_hass(hass) + new_parent = device_registry.async_get_or_create( + config_entry_id=new_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + restored = device_registry.async_get_or_create_child( + config_entry_id=new_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=new_parent.id, + name="Outlet 1", + ) + assert restored.id == child_device.id + assert restored.area_id == "garden" + assert restored.config_entry_id == new_entry.entry_id + + +async def test_child_device_load_and_save( + hass: HomeAssistant, + hass_storage: dict[str, Any], + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test child devices round-trip through the store, unchanged on re-save.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_child_device( + child_device.id, area_id="garden", labels={"outdoor"}, name_by_user="Lamp" + ) + + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + first_save = deepcopy(hass_storage[dr.STORAGE_KEY]["data"]) + await registry2.async_load() + + assert list(device_registry.devices) == list(registry2.devices) + assert list(device_registry.child_devices) == list(registry2.child_devices) + loaded_child = registry2.async_get(child_device.id, include_main_devices=False) + assert loaded_child is not None + assert loaded_child.parent_device_id == parent.id + assert loaded_child.area_id == "garden" + assert loaded_child.labels == {"outdoor"} + assert loaded_child.name_by_user == "Lamp" + assert loaded_child.identifiers == {("test", "strip_outlet_1")} + + # Loading must not silently mutate a child device, so re-saving the freshly + # loaded registry reproduces the same stored data. + registry2.async_schedule_save() + await flush_store(registry2._store) + assert hass_storage[dr.STORAGE_KEY]["data"] == first_save + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_3_3_to_3_4( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 3.3 adds the child devices list.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 3, + "minor_version": 3, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, + "configuration_url": None, + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "abcdefghijklm", + "identifiers": [["test", "strip"]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Power strip", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + assert len(registry.devices) == 1 + assert not registry.child_devices + + await flush_store(registry._store) + assert hass_storage[dr.STORAGE_KEY]["minor_version"] == 4 + assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == [] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_child_device_with_missing_parent( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a stored child device without its parent is dropped with an error.""" + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "child_devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "childdeviceid", + "identifiers": [["test", "strip_outlet_1"]], + "labels": [], + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Outlet 1", + "parent_device_id": "missingparent", + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + assert not registry.child_devices + assert "Dropping child device childdeviceid" in caplog.text + + # The drop scheduled a save, so it persists instead of leaving the store dirty + # until an unrelated write + await flush_store(registry._store) + assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == [] + + +async def test_effective_area_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test effective area resolution for devices and child devices.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert dr.async_get_effective_area_id(hass, parent) is None + assert dr.async_get_effective_area_id(hass, child_device) is None + + # The child inherits the parent's area, resolved at read time + updated_parent = device_registry.async_update_device(parent.id, area_id="garage") + child_device = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert dr.async_get_effective_area_id(hass, child_device) == "garage" + + # An explicitly set area overrides the inherited one + child_device = device_registry.async_update_child_device( + child_device.id, area_id="garden" + ) + assert dr.async_get_effective_area_id(hass, child_device) == "garden" + + # A parent area change is reflected immediately for inheriting children + child_device = device_registry.async_update_child_device( + child_device.id, area_id=None + ) + device_registry.async_update_device(updated_parent.id, area_id="attic") + assert dr.async_get_effective_area_id(hass, child_device) == "attic" + + +@pytest.mark.usefixtures("hass") +async def test_entries_for_area_with_child_devices( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test area queries include child devices by effective area.""" + parent, inheriting_child = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + overriding_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + device_registry.async_update_device(parent.id, area_id="garage") + device_registry.async_update_child_device(overriding_child.id, area_id="garden") + + parent = device_registry.async_get(parent.id) + inheriting_child = device_registry.async_get( + inheriting_child.id, include_main_devices=False + ) + overriding_child = device_registry.async_get( + overriding_child.id, include_main_devices=False + ) + + assert dr.async_entries_for_area(device_registry, "garage") == [ + parent, + inheriting_child, + ] + assert dr.async_entries_for_area(device_registry, "garden") == [overriding_child] + + +@pytest.mark.usefixtures("hass") +async def test_clear_area_id_with_child_devices( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test deleting an area clears explicitly set child device areas.""" + parent, inheriting_child = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_device(parent.id, area_id="garage") + device_registry.async_update_child_device(inheriting_child.id, area_id="garage") + + device_registry.async_clear_area_id("garage") + + updated_parent = device_registry.async_get(parent.id) + updated_child = device_registry.async_get( + inheriting_child.id, include_main_devices=False + ) + assert updated_parent is not None + assert updated_child is not None + assert updated_parent.area_id is None + assert updated_child.area_id is None + + +@pytest.mark.usefixtures("hass") +async def test_clear_label_id_with_child_devices( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test deleting a label removes it from child devices.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_child_device( + child_device.id, labels={"outdoor", "xmas"} + ) + + device_registry.async_clear_label_id("xmas") + + updated_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert updated_child is not None + assert updated_child.labels == {"outdoor"} + + +@pytest.mark.usefixtures("hass") +async def test_entries_for_label_includes_child_devices( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test label queries include child devices carrying the label.""" + parent, labeled_child = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + unlabeled_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + parent = device_registry.async_update_device(parent.id, labels={"label1"}) + labeled_child = device_registry.async_update_child_device( + labeled_child.id, labels={"label1"} + ) + + entries = dr.async_entries_for_label(device_registry, "label1") + assert len(entries) == 2 + assert parent in entries + assert labeled_child in entries + # Labels are never inherited, so a child without the label is excluded even though + # its parent carries it + assert unlabeled_child not in entries + + +async def test_async_cleanup_removes_child_device_with_missing_parent( + hass: HomeAssistant, + hass_storage: dict[str, Any], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test cleanup removes a child device whose parent is gone.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + # Simulate store corruption: drop the parent without the remove cascade + del device_registry.devices[parent.id] + + dr.async_cleanup(hass, device_registry, entity_registry) + + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) is None + ) + assert "Removing child device" in caplog.text + + # The removal scheduled a save, so the drop persists instead of leaving the store + # dirty until an unrelated write + await flush_store(device_registry._store) + assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == [] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_cleanup_removes_child_device_with_stale_config_entry( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test cleanup removes a stored child device whose config entry no longer exists. + + A child device shares its parent's config entry, but a corrupt store can pair a + valid parent with a child naming a config entry that no longer exists. Load only + guards against a missing parent, so the stale config entry is caught by cleanup. + """ + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, + "configuration_url": None, + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "parentdeviceid", + "identifiers": [["test", "strip"]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Power strip", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "child_devices": [ + { + "area_id": None, + "config_entry_id": "stale-config-entry-id", + "config_subentry_id": None, + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "childdeviceid", + "identifiers": [["test", "strip_outlet_1"]], + "labels": [], + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Outlet 1", + "parent_device_id": "parentdeviceid", + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The child loads because its parent is present; the stale config entry is only + # caught by the cleanup sweep below + assert registry.async_get("childdeviceid", include_main_devices=False) is not None + + entity_registry = er.EntityRegistry(hass) + await entity_registry.async_load() + dr.async_cleanup(hass, registry, entity_registry) + + assert registry.async_get("childdeviceid", include_main_devices=False) is None + assert "its config entry stale-config-entry-id no longer exists" in caplog.text + + # The parent, on a valid config entry, is left untouched + assert registry.async_get("parentdeviceid") is not None + + # The removal scheduled a save, so the drop persists + await flush_store(registry._store) + assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == [] + + +@pytest.mark.usefixtures("hass") +async def test_device_info_with_connections_matching_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device info with connections claiming a child's identifier raises. + + Child device identifier collisions are always rejected, even for a stale child + and even when the device info carries connections. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + # A new setup session: the child device is stale, but is still not adopted + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + + with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + identifiers={("test", "strip_outlet_1")}, + name="Not an outlet", + ) + + # The rejection leaves the child device untouched and creates no main device + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) + is child_device + ) + assert len(device_registry.child_devices) == 1 + assert len(device_registry.devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_live_child_device_identifier_collision_raises( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device colliding with a live child device raises.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + hub = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "hub")}, + name="Hub", + ) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + # A device matched by its own identifier that also claims a live child's identifier + # collides with the child and is rejected + with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "hub"), ("test", "strip_outlet_1")}, + name="Hub", + ) + + # The raise precedes reconciliation, so nothing changed + unchanged_child = device_registry.async_get(child_device.id) + assert isinstance(unchanged_child, dr.ChildDeviceEntry) + assert unchanged_child is child_device + assert unchanged_child.parent_device_id == parent.id + assert unchanged_child.identifiers == {("test", "strip_outlet_1")} + assert device_registry.async_get(hub.id) is hub + assert len(device_registry.devices) == 2 + assert len(device_registry.child_devices) == 1 + await hass.async_block_till_done() + assert len(update_events) == 0 + + +async def test_child_and_main_device_same_identifier_across_entries( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child and a main device in different entries can share an identifier. + + Identifiers are unique only within a config entry, so a child device in one entry + coexists with a main device of another entry sharing its identifier, each resolvable + in its own namespace. + """ + entry_a = mock_config_entry + _, child_device = _create_parent_and_child(device_registry, entry_a.entry_id) + + entry_b = MockConfigEntry(title=None) + entry_b.add_to_hass(hass) + main_device = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, + identifiers={("test", "strip_outlet_1")}, + name="Standalone outlet", + ) + + assert isinstance(main_device, dr.DeviceEntry) + assert main_device.id != child_device.id + assert len(device_registry.devices) == 2 + assert len(device_registry.child_devices) == 1 + + # The shared identifier resolves to the child in entry A and the main device in B + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "strip_outlet_1"), entry_a.entry_id + ) + is child_device + ) + assert ( + device_registry.async_get_child_device_by_identifier( + ("test", "strip_outlet_1"), entry_b.entry_id + ) + is None + ) + assert ( + device_registry.async_get_device_by_identifier( + ("test", "strip_outlet_1"), entry_b.entry_id + ) + is main_device + ) + assert ( + device_registry.async_get_device_by_identifier( + ("test", "strip_outlet_1"), entry_a.entry_id + ) + is None + ) + + +@pytest.mark.usefixtures("hass") +async def test_child_device_config_entry_compat_shims( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the deprecated config-entry compatibility shims on a child device entry. + + A child device is keyed by a single config entry and subentry; the deprecated + multi-entry shims inherited from BaseDeviceEntry report that single membership. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert child_device.config_entries == {mock_config_entry.entry_id} + assert child_device.config_entries_subentries == { + mock_config_entry.entry_id: {None} + } + assert child_device.primary_config_entry == mock_config_entry.entry_id + + +@pytest.mark.usefixtures("hass") +async def test_deleted_child_device_restored_as_device_clears_device_disable( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test restoring a device-disabled deleted child as a full device clears it. + + A child disabled by its parent (DeviceEntryDisabler.DEVICE) keeps that disable as a + deleted device; restoring its identifiers as a full device, which has no parent, + drops the parent-device disable. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + disabled_child = device_registry.async_get( + child_device.id, include_main_devices=False + ) + assert disabled_child is not None + assert disabled_child.disabled_by is dr.DeviceEntryDisabler.DEVICE + + device_registry.async_remove_device(child_device.id) + + restored_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + manufacturer="acme", + name="Outlet 1", + ) + assert isinstance(restored_device, dr.DeviceEntry) + assert restored_device.id == child_device.id + assert restored_device.disabled_by is None + + +@pytest.mark.usefixtures("hass") +async def test_deleted_device_disabled_restored_as_child_rederives_disable( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device-disabled deleted device re-derives its disable when restored. + + The stored DeviceEntryDisabler.DEVICE is recomputed from the (now-enabled) parent + on restore, so the restored child is enabled. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device_registry.async_remove_device(child_device.id) + device_registry.async_update_device(parent.id, disabled_by=None) + + restored = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert isinstance(restored, dr.ChildDeviceEntry) + assert restored.id == child_device.id + assert restored.disabled_by is None + + +@pytest.mark.usefixtures("hass") +async def test_deleted_device_restored_as_child_of_disabled_parent( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test restoring a deleted device as a child of a disabled parent disables it.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_remove_device(child_device.id) + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + + restored = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert restored.id == child_device.id + assert restored.disabled_by is dr.DeviceEntryDisabler.DEVICE + + +async def test_deleted_device_restored_as_child_under_disabled_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test restoring a deleted device as a child under a disabled config entry.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.async_remove_device(child_device.id) + + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + + restored = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert restored.id == child_device.id + assert restored.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + +async def test_config_entry_disabled_deleted_device_restored_as_child( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a config-entry-disabled deleted device restores as an enabled child. + + The stored DeviceEntryDisabler.CONFIG_ENTRY is cleared because the config entry is + enabled again by the time the device restores as a child. + """ + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + manufacturer="acme", + name="Outlet 1", + ) + assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + device_registry.async_remove_device(device.id) + + await hass.config_entries.async_set_disabled_by(mock_config_entry.entry_id, None) + await hass.async_block_till_done() + + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + restored = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert restored.id == device.id + assert restored.disabled_by is None + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_legacy_undefined_disabled_deleted_device_restored_as_child( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test restoring a legacy deleted device (disabled_by undefined) as a child. + + A deleted device stored before disabled_by was tracked loads with an undefined + disable; restoring it as a child resolves that to None. + """ + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, + "configuration_url": None, + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "parentdeviceid", + "identifiers": [["test", "strip"]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Power strip", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "child_devices": [], + "deleted_devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": True, + "id": "outletdeviceid", + "identifiers": [["test", "strip_outlet_1"]], + "labels": [], + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": None, + "domain": None, + } + ], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + restored = registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id="parentdeviceid", + name="Outlet 1", + ) + assert isinstance(restored, dr.ChildDeviceEntry) + assert restored.id == "outletdeviceid" + assert restored.disabled_by is None + + +@pytest.mark.usefixtures("hass") +async def test_convert_device_to_child_subentry_mismatch( + device_registry: dr.DeviceRegistry, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test converting a device to a child rejects a config subentry mismatch.""" + entry_id = mock_config_entry_with_subentries.entry_id + parent = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-1", + identifiers={("test", "strip")}, + name="Power strip", + ) + device = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-2", + identifiers={("test", "strip_outlet_1")}, + name="Outlet 1", + ) + + with pytest.raises(dr.DeviceInfoError, match="same config subentry"): + device_registry.async_get_or_create_child( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-1", + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + + # The conversion guard runs before any reconcile, so the device is untouched + assert device_registry.async_get(device.id) is device + assert not device_registry.child_devices + + +async def test_convert_device_with_composite_identifiers_to_child( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test converting a composite-split device to a child replaces its identifiers. + + Identifiers copied from a pre-migration composite are replaced, not merged, so the + extra composite identifier is dropped. + """ + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_1_stale")}, + name="Outlet 1", + via_device_id=parent.id, + ) + device_registry._async_update_device(device.id, has_composite_identifiers=True) + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + converted = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert isinstance(converted, dr.ChildDeviceEntry) + assert converted.id == device.id + assert converted.identifiers == {("test", "strip_outlet_1")} + + await hass.async_block_till_done() + # The conversion reports the replaced identifiers as the old value + assert update_events[0].data["changes"]["identifiers"] == { + ("test", "strip_outlet_1"), + ("test", "strip_outlet_1_stale"), + } + + +async def test_convert_device_to_child_of_disabled_parent( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test converting a device to a child of a disabled parent disables the child.""" + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + name="Outlet 1", + via_device_id=parent.id, + ) + assert device.disabled_by is None + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + converted = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert isinstance(converted, dr.ChildDeviceEntry) + assert converted.id == device.id + assert converted.disabled_by is dr.DeviceEntryDisabler.DEVICE + + await hass.async_block_till_done() + assert update_events[0].data["changes"]["disabled_by"] is None + + +async def test_move_parent_with_pending_move_and_children_rejected( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test completing a deferred move of a parent with child devices is rejected.""" + other_entry = MockConfigEntry(title=None) + other_entry.add_to_hass(hass) + parent, _ = _create_parent_and_child(device_registry, mock_config_entry.entry_id) + + # Arm a deferred move of the parent to another config entry + device_registry._async_update_device( + parent.id, add_config_entry_id=other_entry.entry_id + ) + + # Completing the pending move by removing the current owner is rejected + with pytest.raises(HomeAssistantError, match="has child devices"): + device_registry._async_update_device( + parent.id, remove_config_entry_id=mock_config_entry.entry_id + ) + + unchanged_parent = device_registry.async_get(parent.id) + assert unchanged_parent is not None + assert unchanged_parent.config_entry_id == mock_config_entry.entry_id + assert len(device_registry.child_devices) == 1 + + +@pytest.mark.usefixtures("hass") +async def test_update_child_device_both_identifier_args_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test defining both merge_identifiers and new_identifiers is rejected. + + Only the internal update path can pass both, so the private method is exercised. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + with pytest.raises( + HomeAssistantError, + match="Cannot define both merge_identifiers and new_identifiers", + ): + device_registry._async_update_child_device( + child_device.id, + merge_identifiers={("test", "a")}, + new_identifiers={("test", "b")}, + ) + + +async def test_update_child_disabled_by_none_on_disabled_config_entry_reports( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling a child of a disabled config entry is ignored and reported.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + + caplog.clear() + updated = device_registry.async_update_child_device( + child_device.id, disabled_by=None + ) + assert updated.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + assert ( + "sets disabled_by to None on a child device belonging to the disabled " + "config entry" in caplog.text + ) + + +@pytest.mark.usefixtures("hass") +async def test_update_child_disabled_by_config_entry_on_enabled_entry_reports( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test CONFIG_ENTRY disabling a child of an enabled config entry is ignored.""" + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + updated = device_registry.async_update_child_device( + child_device.id, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY + ) + assert updated.disabled_by is None + assert ( + "sets disabled_by to DeviceEntryDisabler.CONFIG_ENTRY on a child device " + "belonging to the enabled config entry" in caplog.text + ) + + +async def test_create_child_device_under_disabled_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child device created under a disabled config entry is disabled by it.""" + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + + parent = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + assert parent.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + child_device = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent.id, + name="Outlet 1", + ) + assert child_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + +@pytest.mark.usefixtures("hass") +async def test_recreate_child_clears_stale_config_entry_disable( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the create reconciliation clears a stale CONFIG_ENTRY disable. + + A child carrying a CONFIG_ENTRY disable while its config entry is enabled is an + inconsistent leftover; the create-time (is_new) reconciliation clears it. The state + is only reachable internally, so the private update path is exercised. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + device_registry.child_devices[child_device.id] = attr.evolve( + device_registry.async_get(child_device.id, include_main_devices=False), + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + + result = device_registry._async_update_child_device( + child_device.id, is_new=True, merge_identifiers=child_device.identifiers + ) + assert result is not None + assert result.disabled_by is None + + +@pytest.mark.usefixtures("hass") +async def test_update_child_identifiers_purges_colliding_deleted_device( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test adding an identifier to a child purges a colliding deleted device. + + A deleted device holding an identity the child now owns can never restore, so it is + dropped. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + ghost = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "ghost")}, + name="Ghost", + ) + device_registry.async_remove_device(ghost.id) + assert ghost.id in device_registry.deleted_devices + + device_registry.async_update_child_device( + child_device.id, + new_identifiers={("test", "strip_outlet_1"), ("test", "ghost")}, + ) + assert ghost.id not in device_registry.deleted_devices + + +@pytest.mark.usefixtures("hass") +async def test_child_device_identifier_collision_with_other_child( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child device can't take an identifier registered by another child.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + other_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_child_device( + child_device.id, new_identifiers={("test", "strip_outlet_2")} + ) + # The rejected update leaves both children unchanged + assert ( + device_registry.async_get(other_child.id, include_main_devices=False) + is other_child + ) + + +@pytest.mark.usefixtures("hass") +async def test_stale_child_device_identifier_collision_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device claiming a stale child's identifier raises. + + Child device identifier collisions are rejected regardless of whether the child + was registered this setup session; stale children are never stripped or removed. + """ + _, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + hub = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "hub")}, + name="Hub", + ) + # A new setup session: every device of the config entry is now stale + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + + with pytest.raises(dr.DeviceInfoError, match="overlap with those of child device"): + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "hub"), ("test", "strip_outlet_1")}, + name="Hub", + ) + + # The rejection leaves the child device and the hub untouched + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) + is child_device + ) + assert child_device.identifiers == {("test", "strip_outlet_1")} + assert device_registry.async_get(hub.id) is hub + assert hub.identifiers == {("test", "hub")} + + +@pytest.mark.usefixtures("hass") +async def test_get_or_create_child_identifier_owned_by_other_child_raises( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a child registration claiming another child's identifier raises. + + A registration spanning the identifiers of two children is rejected instead of + merging them, even when the children are stale. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + other_child = device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Outlet 2", + ) + # A new setup session: both children are stale + device_registry.async_config_entry_unloaded(mock_config_entry.entry_id) + + with pytest.raises(dr.DeviceInfoError, match="already registered for child"): + device_registry.async_get_or_create_child( + config_entry_id=mock_config_entry.entry_id, + identifiers={("test", "strip_outlet_1"), ("test", "strip_outlet_2")}, + parent_device_id=parent.id, + name="Merged outlet", + ) + + # The rejection leaves both children untouched + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) + is child_device + ) + assert ( + device_registry.async_get(other_child.id, include_main_devices=False) + is other_child + ) + + +@pytest.mark.usefixtures("hass") +async def test_clear_config_entry_removes_orphaned_child_device( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test clearing a config entry removes a child device orphaned by corruption. + + A child always shares its parent's config entry, so the parent cascade removes it; + this defensive sweep only fires for a corrupt store. Deleting the parent from the + live registry without the cascade reproduces that state. + """ + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + del device_registry.devices[parent.id] + + device_registry.async_clear_config_entry(mock_config_entry.entry_id) + + assert ( + device_registry.async_get(child_device.id, include_main_devices=False) is None + ) + assert not device_registry.child_devices + + +@pytest.mark.usefixtures("hass") +async def test_clear_config_subentry_removes_orphaned_child_device( + device_registry: dr.DeviceRegistry, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test clearing a config subentry removes only that subentry's orphaned children. + + A child always shares its parent's subentry, so the parent cascade removes it; this + defensive sweep only fires for a corrupt store, and skips children in another + subentry. Deleting the parents from the live registry reproduces that state. + """ + entry_id = mock_config_entry_with_subentries.entry_id + parent_1 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-1", + identifiers={("test", "strip_1")}, + name="Strip 1", + ) + child_1 = device_registry.async_get_or_create_child( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-1", + identifiers={("test", "outlet_1")}, + parent_device_id=parent_1.id, + name="Outlet 1", + ) + parent_2 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-2", + identifiers={("test", "strip_2")}, + name="Strip 2", + ) + child_2 = device_registry.async_get_or_create_child( + config_entry_id=entry_id, + config_subentry_id="mock-subentry-id-1-2", + identifiers={("test", "outlet_2")}, + parent_device_id=parent_2.id, + name="Outlet 2", + ) + del device_registry.devices[parent_1.id] + del device_registry.devices[parent_2.id] + + device_registry.async_clear_config_subentry(entry_id, "mock-subentry-id-1-1") + + # The child in the cleared subentry is removed, the one in the other subentry kept + assert device_registry.async_get(child_1.id, include_main_devices=False) is None + assert device_registry.async_get(child_2.id, include_main_devices=False) is not None diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e5bc93f98c578e..e84fb9c69731c0 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2952,3 +2952,207 @@ async def async_setup_entry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text + + +async def test_device_info_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a child device info creates a child device and binds the entity.""" + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(config_entry.domain, "strip")}, + name="Power strip", + ) + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="power", + has_entity_name=True, + name="Power", + device_info={ + "identifiers": {(config_entry.domain, "strip_outlet_1")}, + "name": "Outlet 1", + "parent_device_id": parent.id, + }, + ), + ] + ) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + child_device = device_registry.async_get_child_device_by_identifier( + (config_entry.domain, "strip_outlet_1"), config_entry.entry_id + ) + assert child_device is not None + assert child_device.parent_device_id == parent.id + assert child_device.name == "Outlet 1" + + entity_id = entity_registry.async_get_entity_id( + "test_domain", config_entry.domain, "power" + ) + # The child device's name is the device part of the generated entity id + assert entity_id == "test_domain.outlet_1_power" + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id == child_device.id + + # The child device's name is the device part of the entity name + state = hass.states.get(entity_id) + assert state is not None + assert state.name == "Outlet 1 Power" + + +async def test_device_info_child_device_invalid( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an entity with an invalid child device info is not added.""" + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="power", + device_info={ + "identifiers": {(config_entry.domain, "strip_outlet_1")}, + "name": "Outlet 1", + "parent_device_id": "nonexistent-device-id", + }, + ), + ] + ) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert not hass.states.async_entity_ids() + assert not device_registry.child_devices + assert "Not adding entity with invalid device info" in caplog.text + + +async def test_device_info_parent_device_id_routing( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test parent_device_id in device info routes to a child or a main device. + + A device info carrying a parent_device_id creates a child device, while one + without a parent_device_id creates a main device. + """ + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(config_entry.domain, "strip")}, + name="Power strip", + ) + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="child", + device_info={ + "identifiers": {(config_entry.domain, "child")}, + "name": "Child", + "parent_device_id": parent.id, + }, + ), + MockEntity( + unique_id="main", + device_info={ + "identifiers": {(config_entry.domain, "main")}, + "name": "Main", + }, + ), + MockEntity( + unique_id="main_explicit_none", + device_info={ + "identifiers": {(config_entry.domain, "main_none")}, + "name": "Main explicit none", + "parent_device_id": None, + }, + ), + ] + ) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + # A parent_device_id routes to a child device, not a main device + child_device = device_registry.async_get_child_device_by_identifier( + (config_entry.domain, "child"), config_entry.entry_id + ) + assert isinstance(child_device, dr.ChildDeviceEntry) + assert child_device.parent_device_id == parent.id + assert ( + device_registry.async_get_device_by_identifier( + (config_entry.domain, "child"), config_entry.entry_id + ) + is None + ) + + # A device info without a parent_device_id routes to a main device, not a child + main_device = device_registry.async_get_device_by_identifier( + (config_entry.domain, "main"), config_entry.entry_id + ) + assert isinstance(main_device, dr.DeviceEntry) + assert ( + device_registry.async_get_child_device_by_identifier( + (config_entry.domain, "main"), config_entry.entry_id + ) + is None + ) + + # An explicit parent_device_id=None routes to a main device, not a child + main_none_device = device_registry.async_get_device_by_identifier( + (config_entry.domain, "main_none"), config_entry.entry_id + ) + assert isinstance(main_none_device, dr.DeviceEntry) + assert ( + device_registry.async_get_child_device_by_identifier( + (config_entry.domain, "main_none"), config_entry.entry_id + ) + is None + ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 52b5a09ff73888..b75f66280a3313 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -597,6 +597,97 @@ async def test_entity_load_detaches_from_dropped_device( assert entity.device_id is None +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_load_keeps_child_device( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """An entity on a child device keeps its device id on load. + + The composite-split migration remaps entity device ids on load; a child device is + not a composite and is in its own container, so an entity on it must keep its device + id rather than be detached. + """ + mock_config_entry = MockConfigEntry() + mock_config_entry.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, + "configuration_url": None, + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "parentdeviceid", + "identifiers": [["test", "strip"]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Power strip", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "child_devices": [ + { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "childdeviceid", + "identifiers": [["test", "strip_outlet_1"]], + "labels": [], + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "name": "Outlet 1", + "parent_device_id": "parentdeviceid", + } + ], + "deleted_devices": [], + }, + } + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.child_entity", + "device_id": "childdeviceid", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + dr.async_setup(hass) + await asyncio.gather(er.async_load(hass), dr.async_load(hass)) + + registry = er.async_get(hass) + entity = registry.async_get("test.child_entity") + assert entity is not None + assert entity.device_id == "childdeviceid" + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -2268,6 +2359,7 @@ async def test_migration_1_21( "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, "data": { + "child_devices": [], "devices": [ { "area_id": None, @@ -6174,3 +6266,185 @@ async def test_async_entries_for_device_composite_id( entry.entity_id for entry in er.async_entries_for_device(entity_registry, old_id) } == {entity_1.entity_id, entity_2.entity_id} + + +async def test_async_get_effective_area_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test effective area resolution for entities on child devices.""" + config_entry = MockConfigEntry(title=None) + config_entry.add_to_hass(hass) + 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") + 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.id, + name="Outlet 1", + ) + + entry = entity_registry.async_get_or_create( + "switch", + "test", + "outlet_1", + config_entry=config_entry, + device_id=child_device.id, + ) + + # The entity inherits the child device's effective area (the parent's area) + assert er.async_get_effective_area_id(hass, entry) == "garage" + + # An explicitly set child device area overrides the inherited one + device_registry.async_update_child_device(child_device.id, area_id="garden") + assert er.async_get_effective_area_id(hass, entry) == "garden" + + # An explicitly set entity area overrides the device area + entry = entity_registry.async_update_entity(entry.entity_id, area_id="attic") + assert er.async_get_effective_area_id(hass, entry) == "attic" + + # An entity without an area and without a device has no effective area + entry_without_device = entity_registry.async_get_or_create( + "switch", "test", "no_device" + ) + assert er.async_get_effective_area_id(hass, entry_without_device) is None + + # An entity whose device no longer exists has no effective area + entry_missing_device = attr.evolve( + entry, area_id=None, device_id="non_existent_device_id" + ) + assert er.async_get_effective_area_id(hass, entry_missing_device) is None + + +async def test_disable_child_device_disables_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test disabling a parent device disables entities on its child devices.""" + config_entry = MockConfigEntry(title=None) + config_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + 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.id, + name="Outlet 1", + ) + entry = entity_registry.async_get_or_create( + "switch", + "test", + "outlet_1", + config_entry=config_entry, + device_id=child_device.id, + ) + + device_registry.async_update_device( + parent.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + await hass.async_block_till_done() + + updated_entry = entity_registry.async_get(entry.entity_id) + assert updated_entry is not None + assert updated_entry.disabled_by is er.RegistryEntryDisabler.DEVICE + + device_registry.async_update_device(parent.id, disabled_by=None) + await hass.async_block_till_done() + + updated_entry = entity_registry.async_get(entry.entity_id) + assert updated_entry is not None + assert updated_entry.disabled_by is None + + +async def test_remove_child_device_removes_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test removing a parent device removes entities on its child devices.""" + config_entry = MockConfigEntry(title=None) + config_entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + 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.id, + name="Outlet 1", + ) + entry = entity_registry.async_get_or_create( + "switch", + "test", + "outlet_1", + config_entry=config_entry, + device_id=child_device.id, + ) + + device_registry.async_remove_device(parent.id) + await hass.async_block_till_done() + + assert entity_registry.async_get(entry.entity_id) is None + + +async def test_remove_child_device_orphans_foreign_entry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test removing a child device removes same-entry but orphans foreign entities. + + A child device is treated like a main device: an entity of the child's own + config entry is removed, while an entity of a different config entry is detached + (device_id set to None) rather than removed. + """ + config_entry = MockConfigEntry(domain="test", title=None) + config_entry.add_to_hass(hass) + foreign_config_entry = MockConfigEntry(domain="some_helper") + foreign_config_entry.add_to_hass(hass) + + parent = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + 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.id, + name="Outlet 1", + ) + + same_entry_entity = entity_registry.async_get_or_create( + "switch", + "test", + "outlet_1", + config_entry=config_entry, + device_id=child_device.id, + ) + foreign_entry_entity = entity_registry.async_get_or_create( + "sensor", + "some_helper", + "outlet_1_power", + config_entry=foreign_config_entry, + device_id=child_device.id, + ) + + device_registry.async_remove_device(child_device.id) + await hass.async_block_till_done() + + assert entity_registry.async_get(same_entry_entity.entity_id) is None + foreign_entity = entity_registry.async_get(foreign_entry_entity.entity_id) + assert foreign_entity is not None + assert foreign_entity.device_id is None diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index bbbb03ac34a9cd..7322453ecf585b 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -670,6 +670,63 @@ async def test_async_remove_helper_devices_fork( ) +async def test_async_remove_helper_devices_fork_child_source( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A helper's fork of a child source device is relinked to the child. + + A ChildDeviceEntry has no connections, so the connections match is skipped; the + identifier match still finds and removes the helper's fork of the child. A child is + a concrete device, so the fork's entity is relinked to the child, not detached. + """ + source_config_entry = MockConfigEntry(domain=SOURCE_DOMAIN) + source_config_entry.add_to_hass(hass) + helper_config_entry = MockConfigEntry(domain=HELPER_DOMAIN) + helper_config_entry.add_to_hass(hass) + + parent_device = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={(SOURCE_DOMAIN, "parent")}, + ) + source_child = device_registry.async_get_or_create_child( + config_entry_id=source_config_entry.entry_id, + parent_device_id=parent_device.id, + identifiers={(SOURCE_DOMAIN, "child")}, + ) + assert isinstance(source_child, dr.ChildDeviceEntry) + # The helper forked the child by copying its identifiers into device_info + fork = device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={(SOURCE_DOMAIN, "child")}, + ) + assert fork.id != source_child.id + helper_entity_entry = entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + "1", + config_entry=helper_config_entry, + device_id=fork.id, + ) + + async_remove_helper_devices( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + source_device_id=source_child.id, + ) + + # The fork is removed by identifier match, without crashing on the child's + # absent connections; the child source device itself is left untouched. + assert device_registry.async_get(fork.id) is None + assert device_registry.async_get(source_child.id) is not None + # A child is a concrete relink target, so the fork's entity is relinked to the child. + assert ( + entity_registry.async_get(helper_entity_entry.entity_id).device_id + == source_child.id + ) + + async def test_async_remove_helper_devices_sweep( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 4f0f60106c6bfc..978eeaf52d3268 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -607,6 +607,55 @@ async def test_match_device_area( ) == [state1] +async def test_match_child_device_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test async_match_states with an entity on a child device. + + The child device inherits its parent's area. + """ + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + area_kitchen = area_registry.async_get_or_create("kitchen") + + 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=area_kitchen.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", + ) + + state1 = State( + "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + state2 = State( + "light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"} + ) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity(state1.entity_id, device_id=child_device.id) + + # The entity is matched through the child device's inherited area + assert list( + intent.async_match_states( + hass, + domains={"light"}, + area_name="kitchen", + states=[state1, state2], + ) + ) == [state1] + + def test_async_validate_slots() -> None: """Test async_validate_slots of IntentHandler.""" handler1 = MockIntentHandler({vol.Required("name"): cv.string}) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 93b9adf7a67d60..e42cbc4eb38990 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -1080,6 +1080,7 @@ async def test_target_trickle_down_to_splits( } dr.async_setup(hass) + await ar.async_load(hass) await dr.async_load(hass) await er.async_load(hass) device_registry = dr.async_get(hass) @@ -1097,3 +1098,188 @@ async def test_target_trickle_down_to_splits( assert selected.referenced_devices == splits assert COMPOSITE_ID not in selected.referenced_devices assert selected.indirectly_referenced == {"sensor.a", "sensor.b"} + + # A child of a split device is reached too, matching a direct device target + split_a = next( + d + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + if entry_a.entry_id in d.config_entries + ) + child = device_registry.async_get_or_create_child( + config_entry_id=entry_a.entry_id, + identifiers={("domain_a", "child")}, + parent_device_id=split_a.id, + name="Child", + ) + entity_registry = er.async_get(hass) + child_entity = entity_registry.async_get_or_create( + "sensor", + "domain_a", + "child", + config_entry=entry_a, + device_id=child.id, + ).entity_id + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": COMPOSITE_ID}) + ) + assert selected.referenced_devices == splits | {child.id} + assert selected.indirectly_referenced == {"sensor.a", "sensor.b", child_entity} + + +@pytest.fixture +def child_device_setup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> dict[str, str]: + """Create a parent device with child devices and entities. + + The parent is in the garage with the label strip-label. Outlet 1 inherits the + parent's area, outlet 2 has its own area (garden) and label (outlet-label). + """ + config_entry = MockConfigEntry(title=None) + config_entry.add_to_hass(hass) + 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", labels={"strip-label"} + ) + outlet_1 = 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", + ) + outlet_2 = 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( + outlet_2.id, area_id="garden", labels={"outlet-label"} + ) + + entity_ids: dict[str, str] = {} + for key, object_id, device_id in ( + ("strip_switch", "strip", parent.id), + ("outlet_1_switch", "outlet_1", outlet_1.id), + ("outlet_2_switch", "outlet_2", outlet_2.id), + ): + entity_ids[key] = entity_registry.async_get_or_create( + "switch", + "test", + object_id, + config_entry=config_entry, + device_id=device_id, + suggested_object_id=object_id, + ).entity_id + # An entity on outlet 1 with an explicitly set area + own_area_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "outlet_1_energy", + config_entry=config_entry, + device_id=outlet_1.id, + suggested_object_id="outlet_1_energy", + ) + entity_registry.async_update_entity(own_area_entry.entity_id, area_id="attic") + entity_ids["outlet_1_energy"] = own_area_entry.entity_id + + return { + "parent": parent.id, + "outlet_1": outlet_1.id, + "outlet_2": outlet_2.id, + **entity_ids, + } + + +async def test_extract_parent_device_includes_child_devices( + hass: HomeAssistant, + child_device_setup: dict[str, str], +) -> None: + """Test targeting a parent device expands to its child devices' entities.""" + ids = child_device_setup + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": ids["parent"]}) + ) + assert selected.referenced_devices == { + ids["parent"], + ids["outlet_1"], + ids["outlet_2"], + } + assert selected.indirectly_referenced == { + ids["strip_switch"], + ids["outlet_1_switch"], + ids["outlet_2_switch"], + ids["outlet_1_energy"], + } + + # Targeting a child device directly targets only the child device + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": ids["outlet_1"]}) + ) + assert selected.referenced_devices == {ids["outlet_1"]} + assert selected.indirectly_referenced == { + ids["outlet_1_switch"], + ids["outlet_1_energy"], + } + + +async def test_extract_area_with_child_devices( + hass: HomeAssistant, + child_device_setup: dict[str, str], +) -> None: + """Test area targeting includes child devices by effective area.""" + ids = child_device_setup + + # The garage contains the parent and the inheriting child device; the entity + # with its own area and the child device with its own area are not included + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"area_id": "garage"}) + ) + assert selected.referenced_devices == {ids["parent"], ids["outlet_1"]} + assert selected.indirectly_referenced == { + ids["strip_switch"], + ids["outlet_1_switch"], + } + + # The garden contains only the child device with that area set explicitly + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"area_id": "garden"}) + ) + assert selected.referenced_devices == {ids["outlet_2"]} + assert selected.indirectly_referenced == {ids["outlet_2_switch"]} + + +async def test_extract_label_with_child_devices( + hass: HomeAssistant, + child_device_setup: dict[str, str], +) -> None: + """Test labels are not inherited by child devices when targeting. + + A labeled parent targets only the parent and its own entities; a labeled child + targets only that child. This mirrors dr.async_entries_for_label, which + documents that labels are never inherited from the parent. + """ + ids = child_device_setup + + # The label is on the parent; it does not expand to the child devices, and only + # the parent's own entities are indirectly referenced. + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"label_id": "strip-label"}) + ) + assert selected.referenced_devices == {ids["parent"]} + assert selected.indirectly_referenced == {ids["strip_switch"]} + + # A label on a child device targets only the child device + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"label_id": "outlet-label"}) + ) + assert selected.referenced_devices == {ids["outlet_2"]} + assert selected.indirectly_referenced == {ids["outlet_2_switch"]} diff --git a/tests/syrupy.py b/tests/syrupy.py index aa1b5446e942f4..653cf56cb5a1f8 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -104,6 +104,8 @@ def _serialize( serializable_data = cls._serializable_area_registry_entry(data) elif isinstance(data, dr.DeviceEntry): serializable_data = cls._serializable_device_registry_entry(data) + elif isinstance(data, dr.ChildDeviceEntry): + serializable_data = cls._serializable_child_device_registry_entry(data) elif isinstance(data, er.RegistryEntry): serializable_data = cls._serializable_entity_registry_entry(data) elif isinstance(data, ir.IssueEntry): @@ -182,6 +184,25 @@ def _serializable_device_registry_entry( return cls._remove_created_and_modified_at(serialized) + @classmethod + def _serializable_child_device_registry_entry( + cls, data: dr.ChildDeviceEntry + ) -> SerializableData: + """Prepare a Home Assistant child device registry entry for serialization.""" + serialized = DeviceRegistryEntrySnapshot( + attr.asdict( + data, + retain_collection_types=True, + filter=lambda attribute, _: not attribute.name.startswith("_"), + ) + | {"id": ANY} + ) + serialized["config_entry_id"] = ANY + serialized["config_subentry_id"] = ANY + serialized["parent_device_id"] = ANY + + return cls._remove_created_and_modified_at(serialized) + @classmethod def _remove_created_and_modified_at( cls, data: SerializableData