diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 953743c66a6a6e..aa68ddbf52448f 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(entry.entry_id) + dev_reg.async_clear_config_entry(entry.entry_id, entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 47cf6aa5ea8845..e7f2cdb540c854 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 7aa6d4d8a7ac5b..175c35c8297fb1 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, "tracked_devices": [], } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index b2fa4bbb06f058..f7c5bfc32b5c96 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -43,6 +43,32 @@ } +def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: + """Resolve a device automation device id, following a composite device id. + + A device automation created when a device could be connected to more than one + config entry stores the id of the (now removed) composite device. When the + automation's domain owns one of the split devices' config entries, resolve to that + device - an integration may look the device up in its own registry, which only + knows the current device id, not the removed composite id. + """ + device_registry = dr.async_get(hass) + if device_id in device_registry.devices: + return device_id + if not ( + split_devices := device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ): + return device_id + # Resolve to the device owned by a config entry of the automation's domain + for split_device in split_devices: + entry = hass.config_entries.async_get_entry(split_device.config_entry_id) + if entry is not None and entry.domain == domain: + return split_device.id + return device_id + + async def async_validate_device_automation_config( hass: HomeAssistant, config: ConfigType, @@ -51,6 +77,17 @@ async def async_validate_device_automation_config( ) -> ConfigType: """Validate config.""" validated_config: ConfigType = automation_schema(config) + + # A device automation may reference a pre-migration composite device id; resolve it + # to the split device for its domain so the device and its entities exist and the + # integration platform (validation and attach/call) receives a live device id + resolved_device_id = _resolve_device_id( + hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN] + ) + if resolved_device_id != validated_config[CONF_DEVICE_ID]: + config = {**config, CONF_DEVICE_ID: resolved_device_id} + validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id} + platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 9d4b530930559c..bca2cc73fd9ea5 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -36,9 +36,14 @@ from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType -from .util import async_redact_data, entity_entry_as_dict - -__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"] +from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict + +__all__ = [ + "REDACTED", + "async_redact_data", + "device_entry_as_dict", + "entity_entry_as_dict", +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 5dd6085e2df0ba..9326961c5d8cfb 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -6,6 +6,7 @@ import attr from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: return cast(_T, redacted) +# DeviceEntry attributes that are internal bookkeeping and must not be exposed in +# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient +# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter. +# The composite-device migration attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + + +def _device_entry_filter(a: attr.Attribute, _: Any) -> bool: + return ( + not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ) + + +@callback +def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]: + """Convert a device registry entry to a dict for diagnostics. + + This excludes internal fields that should not be exposed in diagnostics. + """ + return attr.asdict(entry, filter=_device_entry_filter) + + def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7945f39aeb2961..67818456dbe34f 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry( """Set up a config entry.""" device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): - device_registry.async_clear_config_entry(entry.entry_id) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 77d7c2a4dc978d..7806ec781a238b 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -5,11 +5,14 @@ from typing import TYPE_CHECKING, Any from aiohttp import ClientResponse -from attr import asdict from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context", None) entity_dict = entity_entry_as_dict(entity) entities.append({"entity": entity_dict, "state": state_dict}) - device_dict = asdict(device) - device_dict.pop("_cache", None) - # This can be removed when suggested_area is removed from DeviceEntry - device_dict.pop("_suggested_area") + device_dict = device_entry_as_dict(device) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index a3166d15888d42..dc45e57ea2fbee 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity_entry), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "coordinator_data": coordinator.data.to_dict(), diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index eb90737faba392..89a04a4b143dd6 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -3,9 +3,11 @@ from dataclasses import asdict from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, # Gather information how this device is represented in Home Assistant entity_registry = er.async_get(hass) - data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG) data["entities"] = [] entities: list[dict[str, Any]] = data["entities"] diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 4feef90a7f7289..ffe8bed9117f5e 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.4.0"] + "requirements": ["intellifire4py==4.5.0"] } diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 0e614a8b363b5b..5cce4f3857a392 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -4,12 +4,13 @@ import logging from typing import Any, override +from aiohttp import ClientError from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState from homeassistant.core import callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,3 +113,10 @@ async def async_call_api( if on_fail_method: on_fail_method() raise ServiceValidationError(exc) from exc + except (TimeoutError, ClientError) as exc: + if on_fail_method: + on_fail_method() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 4ded58f514fde9..dd739a8ca21ee3 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -1163,6 +1163,9 @@ } }, "exceptions": { + "connection_error": { + "message": "Failed to connect to the LG ThinQ cloud. Please try again later." + }, "failed_to_connect_mqtt": { "message": "Failed to connect MQTT: {error}" } diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 1bda5ab4e4d5a3..06b965ae3cb681 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics( assert hass_device is not None data["device"] = { - **attr.asdict(hass_device), + **device_entry_as_dict(hass_device), "entities": {}, } @@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), + **entity_dict, "state": state_dict, } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 449c0722bde115..c07ca2fb887373 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -244,7 +244,7 @@ async def async_migrate_entry( # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(config_entry.entry_id) + dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index ea2f37f11f5225..bc929103df139a 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -45,6 +45,7 @@ class PlayerData: loccityid: int | None = None gameextrainfo: str | None = None gameid: str | None = None + lobbysteamid: str | None = None level: int | None = None diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 247c057d030aa0..844aed33075a8a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -708,13 +708,11 @@ async def async_migrate_entry( updated, ) - # version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device - # (linked to the bot device) and strip the chat subentries from the bot device, leaving - # it associated with only (entry, None). + # version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device, + # and make sure the bot device is tied to (entry, None). if version == 1 and config_entry.minor_version < 3: device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - # Up to 1.2 the entry has a single device, the bot device, shared by every chat devices = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) @@ -738,18 +736,16 @@ async def async_migrate_entry( config_entry_id=config_entry.entry_id, config_subentry_id=subentry_id, identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, - via_device=(DOMAIN, bot_id), + via_device_id=bot_device.id, ) if entity := notify_entities.get(subentry_id): entity_registry.async_update_entity( entity.entity_id, device_id=per_chat_device.id ) - # Strip this chat's subentry from the bot device, leaving (entry, None) - device_registry.async_update_device( - bot_device.id, - remove_config_entry_id=config_entry.entry_id, - remove_config_subentry_id=subentry_id, - ) + # Hand the bot device back to (entry, None), keeping the event entity + device_registry.async_update_device( + bot_device.id, new_config_subentry_id=None + ) hass.config_entries.async_update_entry(config_entry, minor_version=3) return True diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index b8f5a119540455..681eedfef4c9fc 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "entry": config_entry.as_dict(), diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 61908b84b513c6..a14b7b43f7ff06 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.7.0"], + "requirements": ["victron-mqtt==2026.7.4"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 2f88854cfd89e9..dc4cd29e0326d8 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -403,6 +403,13 @@ "passthrough": "[%key:component::victron_gx::common::passthrough%]" } }, + "battery_bms_mode": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "standby": "[%key:common::state::standby%]" + } + }, "evcharger_mode": { "name": "[%key:common::config_flow::data::mode%]", "state": { @@ -1899,6 +1906,16 @@ "system_heartbeat": { "name": "GX system heartbeat" }, + "system_pv_on_grid_current_phase": { + "name": "PV on grid current {phase}" + }, + "system_pv_on_grid_phases": { + "name": "PV on grid phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_grid_power_phase": { + "name": "PV on grid power {phase}" + }, "system_pv_on_output_current_phase": { "name": "PV on output current {phase}" }, diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 4f74c34e7a50b7..2724a87d307933 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -22,6 +22,7 @@ Platform.BUTTON, Platform.CLIMATE, Platform.LIGHT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/whirlpool/number.py b/homeassistant/components/whirlpool/number.py new file mode 100644 index 00000000000000..a76c440145048d --- /dev/null +++ b/homeassistant/components/whirlpool/number.py @@ -0,0 +1,79 @@ +"""Number platform for the Whirlpool Appliances integration.""" + +from typing import override + +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + +# Oven target temperatures are handled in Celsius. The appliance accepts +# tenth-of-a-degree values, so a 1-degree step gives fine manual control while +# automations can still set any value Home Assistant passes through. +OVEN_MIN_TEMP = 30 +OVEN_MAX_TEMP = 290 +OVEN_TEMP_STEP = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenTargetTemperature(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenTargetTemperature(WhirlpoolOvenEntity, NumberEntity): + """Settable target temperature for an oven cavity.""" + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = OVEN_MIN_TEMP + _attr_native_max_value = OVEN_MAX_TEMP + _attr_native_step = OVEN_TEMP_STEP + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven target temperature number.""" + super().__init__( + appliance, cavity, "oven_target_temperature", "-target_temperature" + ) + + @override + @property + def native_value(self) -> float | None: + """Return the current target temperature.""" + return self._appliance.get_target_temp(self.cavity) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new target temperature, keeping the current cook mode.""" + mode = self._appliance.get_cook_mode(self.cavity) + if mode is None or mode == CookMode.Standby: + mode = CookMode.Bake + try: + WhirlpoolOvenTargetTemperature._check_service_request( + await self._appliance.set_cook( + target_temp=value, mode=mode, cavity=self.cavity + ) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index c2b31c64148664..14c695db99fb73 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -258,6 +258,9 @@ class WhirlpoolOvenCavitySensorEntityDescription(SensorEntityDescription): ), ) +# Sensors replaced by more capable entities (select and number respectively). +DEPRECATED_OVEN_SENSOR_KEYS = ("oven_cook_mode", "oven_target_temperature") + def _build_oven_cavity_sensors( hass: HomeAssistant, @@ -269,14 +272,15 @@ def _build_oven_cavity_sensors( suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity) sensors: list[SensorEntity] = [] for description in OVEN_CAVITY_SENSORS: - # The oven cook mode sensor has been replaced by a select entity. - if description.key == "oven_cook_mode" and not deprecate_entity( + # The oven cook mode and target temperature sensors have been replaced + # by select and number entities respectively. + if description.key in DEPRECATED_OVEN_SENSOR_KEYS and not deprecate_entity( hass, entity_registry, platform_domain=Platform.SENSOR, - entity_unique_id=f"{oven.said}-oven_cook_mode{suffix}", - issue_id=f"deprecated_oven_cook_mode_{oven.said}{suffix}", - translation_key="deprecated_oven_cook_mode", + entity_unique_id=f"{oven.said}-{description.key}{suffix}", + issue_id=f"deprecated_{description.key}_{oven.said}{suffix}", + translation_key=f"deprecated_{description.key}", ): continue sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description)) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index d0859130434011..75a70e8e89b41f 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -68,6 +68,17 @@ "name": "Upper oven light" } }, + "number": { + "oven_target_temperature": { + "name": "Target temperature" + }, + "oven_target_temperature_lower": { + "name": "Lower oven target temperature" + }, + "oven_target_temperature_upper": { + "name": "Upper oven target temperature" + } + }, "select": { "oven_cook_mode": { "name": "Cook mode", @@ -298,6 +309,14 @@ "deprecated_oven_cook_mode_scripts": { "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.", "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]" + }, + "deprecated_oven_target_temperature": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nUpdate any dashboards, templates, automations or scripts to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven target temperature sensor is deprecated" + }, + "deprecated_oven_target_temperature_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_target_temperature::title%]" } } } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 520c89e7f399c0..73b56a8dae354d 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -850,17 +850,22 @@ def _async_device_listener() -> None: if new_devices: device_registry = dr.async_get(hass) for device_id in new_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - if any( - ( - config_entry := hass.config_entries.async_get_entry( - config_entry_id - ) + # The same sub-device can be reported by several config entries, each + # owning its own device registry entry. Its sensors share a unique id + # across config entries, so only create them if no other loaded config + # entry already provides them. + if any( + ( + config_entry := hass.config_entries.async_get_entry( + device.config_entry_id ) - and config_entry.state is ConfigEntryState.LOADED - for config_entry_id in device.config_entries - ): - continue + ) + and config_entry.state is ConfigEntryState.LOADED + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ) + ): + continue async_add_entities( WithingsDeviceSensor(device_coordinator, description, device_id) for description in DEVICE_SENSORS @@ -870,11 +875,17 @@ def _async_device_listener() -> None: if old_devices: device_registry = dr.async_get(hass) for device_id in old_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - current_devices.remove(device_id) + # Several config entries can share this identifier, each owning its own + # device registry entry, so only remove this entry's own device. + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ): + if device.config_entry_id == entry.entry_id: + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + break + current_devices.remove(device_id) device_coordinator.async_add_listener(_async_device_listener) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2edbb3c035f808..9f1e2839be954b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2133,6 +2133,7 @@ def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) + self._initialized = asyncio.Event() EntityRegistryDisabledHandler(hass).async_setup() @callback @@ -2277,7 +2278,7 @@ def _async_clean_up(self, entry: ConfigEntry) -> None: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id, entry.domain) ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should @@ -2302,6 +2303,7 @@ async def async_initialize(self) -> None: if config is None: self._entries = ConfigEntryItems(self.hass) + self._initialized.set() return entries: ConfigEntryItems = ConfigEntryItems(self.hass) @@ -2341,6 +2343,12 @@ async def async_initialize(self) -> None: EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries ) + self._initialized.set() + + async def async_wait_initialized(self) -> None: + """Wait until the config entries are loaded from storage.""" + await self._initialized.wait() + async def _async_scan_orphan_ignored_entries( self, event: Event[NoEventData] ) -> None: @@ -2686,7 +2694,7 @@ def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain) ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) return result diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index daf373a18624f8..10c015f5520db0 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,11 +2,15 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Set as AbstractSet +import copy +from dataclasses import dataclass from datetime import datetime from enum import StrEnum from functools import lru_cache import logging +import os +import shutil import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override @@ -32,7 +36,12 @@ from . import storage, translation from .debounce import Debouncer from .deprecation import deprecated_function -from .frame import ReportBehavior, report_usage +from .frame import ( + MissingIntegrationFrame, + ReportBehavior, + get_integration_frame, + report_usage, +) from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .typing import UNDEFINED, UndefinedType @@ -54,8 +63,8 @@ "device_registry_updated" ) STORAGE_KEY = "core.device_registry" -STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 12 +STORAGE_VERSION_MAJOR = 3 +STORAGE_VERSION_MINOR = 1 CLEANUP_DELAY = 10 @@ -66,8 +75,32 @@ ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 -# Can be removed when suggested_area is removed from DeviceEntry -RUNTIME_ONLY_ATTRS = {"suggested_area"} +# suggested_area can be removed when suggested_area is removed from DeviceEntry. +# pending_move can be removed once add_config_entry_id and remove_config_entry_id +# are removed from the device registry API. +RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"} + + +@dataclass(frozen=True, slots=True) +class _PendingMove: + """A deferred config-entry move recorded by add_config_entry_id. + + A later remove_config_entry_id from the same integration (origin_domain) completes + the move; one from a different integration cancels it. Runtime-only, never stored. + """ + + config_entry_id: str + config_subentry_id: str | None + origin_domain: str | None + + +def _current_integration_domain() -> str | None: + """Return the domain of the integration in the current call stack, if any.""" + try: + return get_integration_frame().integration + except MissingIntegrationFrame: + return None + CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -102,7 +135,8 @@ class DeviceInfo(TypedDict, total=False): hw_version: str | None translation_key: str | None translation_placeholders: Mapping[str, str] | None - via_device: tuple[str, str] + via_device: tuple[str, str] # Deprecated, use via_device_id instead + via_device_id: str DEVICE_INFO_TYPES = { @@ -127,6 +161,7 @@ class DeviceInfo(TypedDict, total=False): "suggested_area", "sw_version", "via_device", + "via_device_id", }, "secondary": { "connections", @@ -135,14 +170,10 @@ class DeviceInfo(TypedDict, total=False): "default_name", # Used by Fritz "via_device", + "via_device_id", }, } -DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) - -# Integrations which may share a device with a native integration -LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} - class _EventDeviceRegistryUpdatedData_Create(TypedDict): """EventDeviceRegistryUpdated data for action type 'create'.""" @@ -365,9 +396,10 @@ def _normalize_connections_validator( class DeviceEntry: """Device Registry Entry.""" + config_entry_id: str = attr.ib() + area_id: str | None = attr.ib(default=None) - config_entries: set[str] = attr.ib(converter=set, factory=set) - config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) + config_subentry_id: str | None = attr.ib(default=None) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib( converter=set, factory=set, validator=_normalize_connections_validator @@ -379,20 +411,85 @@ class DeviceEntry: 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. + # split_at records when the split happened. + composite_device_id: str | None = attr.ib(default=None) + composite_primary_config_entry: str | None = attr.ib(default=None) + split_at: datetime | None = attr.ib(default=None) 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) - primary_config_entry: 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 + # it provides and this flag is cleared - a one-shot marker, unlike composite_device_id + # which is kept for the device's lifetime so old ids keep resolving; neither can be + # derived from the other. This flag and the replacement logic can be removed in HA + # Core 2027.8. + has_composite_identifiers: bool = attr.ib(default=False) serial_number: str | None = attr.ib(default=None) - # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + # Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9. _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) + # Transient pending move target (config_entry_id, config_subentry_id) initiated by + # add_config_entry_id and completed by a subsequent remove_config_entry_id. It is + # never stored and is not part of equality. Can be removed in HA Core 2027.8. + _pending_move: _PendingMove | None = attr.ib(default=None, eq=False) + # Set only on the read-only composite device that async_get synthesizes on demand + # for a pre-migration composite device id. It holds the union of the split + # devices' config entries and subentries so callers see the pre-split device. It is + # never stored and the composite is never added to the registry. Can be removed in + # HA Core 2027.8. + _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 + 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. + """ + if self._composite_subentries is not None: + return set(self._composite_subentries) + 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. + """ + if self._composite_subentries is not None: + return { + entry_id: set(subentries) + for entry_id, subentries in self._composite_subentries.items() + } + 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.""" @@ -407,11 +504,16 @@ def dict_repr(self) -> dict[str, Any]: return { "area_id": self.area_id, "configuration_url": self.configuration_url, + # config_entries and config_entries_subentries are deprecated and kept for + # backwards compatibility, they can be removed in HA Core 2027.8. They use the + # compatibility properties so a restored composite reports its merged entries. "config_entries": list(self.config_entries), "config_entries_subentries": { entry_id: list(subentries) for entry_id, subentries in self.config_entries_subentries.items() }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -455,15 +557,8 @@ def as_storage_fragment(self) -> json_fragment: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -473,12 +568,18 @@ def as_storage_fragment(self) -> json_fragment: "id": self.id, "identifiers": list(self.identifiers), "labels": list(self.labels), + "composite_device_id": self.composite_device_id, + "composite_primary_config_entry": ( + self.composite_primary_config_entry + ), + "split_at": self.split_at, "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, "modified_at": self.modified_at, "name_by_user": self.name_by_user, "name": self.name, + "has_composite_identifiers": (self.has_composite_identifiers), "primary_config_entry": self.primary_config_entry, "serial_number": self.serial_number, "sw_version": self.sw_version, @@ -496,13 +597,32 @@ def suggested_area(self) -> str | None: return self._suggested_area +# 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 +# drops them with a warning instead of fanning them out. serial_number is intentionally +# NOT here: it describes the physical device and is consistent across a composite's +# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8. +_COMPOSITE_IGNORED_UPDATE_ARGS = ( + "merge_connections", + "merge_identifiers", + "new_config_entry_id", + "new_config_subentry_id", + "new_connections", + "new_identifiers", +) + + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: """Deleted Device Registry Entry.""" + # config_entry_id is None for orphaned deleted devices, i.e. devices whose owning + # config entry has been removed + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + area_id: str | None = attr.ib() - config_entries: set[str] = attr.ib() - config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib( validator=_normalize_connections_validator ) @@ -514,8 +634,30 @@ class DeletedDeviceEntry: modified_at: datetime = attr.ib() name_by_user: str | None = attr.ib() orphaned_timestamp: float | None = attr.ib() + # Domain of the config entry that owns (or owned) this device, recorded when the + # device is deleted so a re-added config entry only restores an orphan from the same + # integration. None for legacy stores. + domain: 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 belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + return {self.config_entry_id} if self.config_entry_id is not None else set() + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + if self.config_entry_id is None: + return {} + return {self.config_entry_id: {self.config_subentry_id}} + def to_device_entry( self, config_entry: ConfigEntry, @@ -537,9 +679,9 @@ def to_device_entry( disabled_by = disabled_by if disabled_by is not UNDEFINED else None return DeviceEntry( area_id=self.area_id, + config_entry_id=config_entry.entry_id, + config_subentry_id=config_subentry_id, # type ignores: likely https://github.com/python/mypy/issues/8625 - config_entries={config_entry.entry_id}, # type: ignore[arg-type] - config_entries_subentries={config_entry.entry_id: {config_subentry_id}}, connections=self.connections & connections, # type: ignore[arg-type] created_at=self.created_at, disabled_by=disabled_by, @@ -556,15 +698,8 @@ def as_storage_fragment(self) -> json_fragment: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at, "disabled_by": self.disabled_by @@ -577,11 +712,23 @@ def as_storage_fragment(self) -> json_fragment: "modified_at": self.modified_at, "name_by_user": self.name_by_user, "orphaned_timestamp": self.orphaned_timestamp, + "domain": self.domain, } ) ) +def _copy_if_exists(source: str, destination: str) -> bool: + """Copy source to destination when source exists (runs in the executor). + + Returns whether the file was copied. + """ + if not os.path.isfile(source): + return False + shutil.copyfile(source, destination) + return True + + class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -593,10 +740,12 @@ async def _async_migrate_func( # noqa: C901 old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - # Support for a future major version bump to 2 added in HA Core 2025.2. - # Major versions 1 and 2 will be the same, except that version 2 will no - # longer store a list of config_entries. + # Note: There's no version 2, it was planned and supported by previous versions + # of the migrator which treated version 2 like version 1. if old_major_version < 3: + # Copy the store before the version 3 migrator rewrites every device, so a + # user can recover the pre-migration registry if the migration misbehaves. + await self._async_backup_store() if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -677,80 +826,277 @@ async def _async_migrate_func( # noqa: C901 # of version 1.10 for device in old_data["deleted_devices"]: device["disabled_by_undefined"] = old_minor_version < 10 - - if old_major_version > 2: + # Version 3 restricts a device to a single config entry and subentry, + # introduced in 2026.8. Composite devices which belonged to several + # config entries (or several subentries of one entry) are split into one + # device per (config entry, subentry). Each split device keeps a copy of + # the identifiers and connections and a reference (composite_device_id) to the original + # composite device id, so that actions targeting the old id still reach + # all split devices. Entities are moved to the matching split device when + # the registries are loaded. + migrated_at = utcnow().isoformat() + devices: list[dict[str, Any]] = [] + # Ids of active devices dropped for lacking a config entry; a retained + # child's via_device_id pointing at one is detached below. + dropped_device_ids: set[str] = set() + # old composite id -> {config entry id -> new split id}, to rewrite + # via_device_id links pointing at a split parent + composite_splits: dict[str, dict[str, str]] = {} + # Active splits whose copied disabled_by must be reconciled against their + # single config entry once the config entries are loaded + migrated_active_splits: list[dict[str, Any]] = [] + for device in old_data["devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if not pairs: + # Drop devices that have no config entry / subentry pairs + dropped_device_ids.add(device["id"]) + continue + if len(pairs) == 1: + config_entry_id, subentry_id = pairs[0] + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["composite_device_id"] = None + device["composite_primary_config_entry"] = None + device["split_at"] = None + device["has_composite_identifiers"] = False + devices.append(device) + continue + old_id = device["id"] + composite_primary = device.get("primary_config_entry") + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["primary_config_entry"] = config_entry_id + split["composite_device_id"] = old_id + split["composite_primary_config_entry"] = composite_primary + split["split_at"] = migrated_at + split["has_composite_identifiers"] = True + devices.append(split) + migrated_active_splits.append(split) + composite_splits.setdefault(old_id, {})[config_entry_id] = split[ + "id" + ] + # Rewrite via_device_id links that pointed at a now-split composite parent + # to a live split: the parent's split in the child's own config entry when + # there is one, otherwise any of the parent's splits, so the link never + # dangles on the removed composite id. A link to a retained unsplit parent is + # left unchanged; a link to a dropped parent is detached below. + for device in devices: + if ( + splits := composite_splits.get(device["via_device_id"]) + ) is not None: + device["via_device_id"] = splits.get( + device["config_entry_id"], next(iter(splits.values())) + ) + elif device["via_device_id"] in dropped_device_ids: + # The parent was dropped (no config entries); detach the link as + # async_remove_device would, so it does not dangle on a removed id. + device["via_device_id"] = None + old_data["devices"] = devices + # A split inherited the composite's disabled_by, which may not match its + # single config entry (e.g. a split owned by an enabled entry must not stay + # CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them + # and reconcile each split against its own entry. + if migrated_active_splits: + await self.hass.config_entries.async_wait_initialized() + for split in migrated_active_splits: + config_entry = self.hass.config_entries.async_get_entry( + split["config_entry_id"] + ) + if config_entry is not None: + _migrate_device_disabled_by( + split, config_entry.disabled_by is not None + ) + deleted_devices: list[dict[str, Any]] = [] + for device in old_data["deleted_devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if len(pairs) <= 1: + # Unlike active devices, config_entry_id=None is a valid + # (orphaned) state for a deleted device, so a deleted device with + # no config entries is kept rather than dropped. + config_entry_id, subentry_id = pairs[0] if pairs else (None, None) + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["domain"] = None + deleted_devices.append(device) + continue + # A deleted device that belonged to several config entries or subentries + # is split like an active one - each split keeps a copy of the + # identifiers/connections so every config entry can still restore its + # share when a matching device is re-registered. + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["domain"] = None + deleted_devices.append(split) + old_data["deleted_devices"] = deleted_devices + # config_entries and config_entries_subentries are deprecated; v3 stores only + # the singular config_entry_id / config_subentry_id (single-entry devices kept + # the old keys, splits copied them via deepcopy). + for migrated in (*devices, *deleted_devices): + migrated.pop("config_entries", None) + migrated.pop("config_entries_subentries", None) + + if old_major_version > 3: raise NotImplementedError return old_data + async def _async_backup_store(self) -> None: + """Copy the store file to a timestamped backup before migrating.""" + source = self.path + backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup" + try: + copied = await self.hass.async_add_executor_job( + _copy_if_exists, source, backup + ) + except OSError as err: + _LOGGER.warning("Could not back up %s before migration: %s", source, err) + else: + if copied: + _LOGGER.info("Backed up %s to %s before migration", source, backup) + class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( BaseRegistryItems[_EntryTypeT] ): """Container for device registry items, maps device id -> entry. - Maintains two additional indexes: - - (connection_type, connection identifier) -> entry - - (DOMAIN, identifier) -> entry + Maintains two additional indexes. An identifier or connection can be shared by + several devices, each belonging to a different config entry, so each index maps a + connection or identifier to the devices that have it, keyed by config entry id: + - (connection_type, connection identifier) -> {config_entry_id: entry} + - (DOMAIN, identifier) -> {config_entry_id: entry} """ def __init__(self) -> None: """Initialize the container.""" super().__init__() - self._connections: dict[tuple[str, str], _EntryTypeT] = {} - self._identifiers: dict[tuple[str, str], _EntryTypeT] = {} + self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} + self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} @override def _index_entry(self, key: str, entry: _EntryTypeT) -> None: """Index an entry.""" + config_entry_id = entry.config_entry_id for connection in entry.connections: - self._connections[connection] = entry + self._connections.setdefault(connection, {})[config_entry_id] = entry for identifier in entry.identifiers: - self._identifiers[identifier] = entry + self._identifiers.setdefault(identifier, {})[config_entry_id] = entry @override def _unindex_entry( self, key: str, replacement_entry: _EntryTypeT | None = None ) -> None: - """Unindex an entry.""" + """Unindex an entry. + + Guards against collisions, the code below can be simplified once + collisions are not longer allowed, refer to commit history in PR + 175785. + """ old_entry = self.data[key] + config_entry_id = old_entry.config_entry_id for connection in old_entry.connections: - if connection in self._connections: - del self._connections[connection] + by_config_entry = self._connections.get(connection) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._connections[connection] for identifier in old_entry.identifiers: - if identifier in self._identifiers: - del self._identifiers[identifier] + by_config_entry = self._identifiers.get(identifier) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._identifiers[identifier] def get_entry( self, identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, + *, + config_entry_id: str | None | UndefinedType = UNDEFINED, ) -> _EntryTypeT | None: - """Get entry from identifiers or connections.""" + """Get the first entry matching identifiers or connections. + + If config_entry_id is given, only an entry belonging to that config entry is + returned. Otherwise the first matching entry from any config entry is returned. + """ if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - return self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] if not connections: return None for connection in _normalize_connections(connections): - if connection in self._connections: - return self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] return None def get_entries( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, - ) -> Iterable[_EntryTypeT]: - """Get entries from identifiers or connections.""" + identifiers: AbstractSet[tuple[str, str]] | None = None, + connections: AbstractSet[tuple[str, str]] | None = None, + ) -> list[_EntryTypeT]: + """Get all entries matching identifiers or connections, across config entries.""" + entries: dict[str, _EntryTypeT] = {} if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - yield self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry if connections: for connection in _normalize_connections(connections): - if connection in self._connections: - yield self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + return list(entries.values()) class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): @@ -759,16 +1105,18 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): def __init__(self) -> None: """Initialize the container. - Maintains three additional indexes: + Maintains four additional indexes: - area_id -> dict[key, True] - config_entry_id -> dict[key, True] - label -> dict[key, True] + - composite_device_id -> dict[key, True] """ super().__init__() self._area_id_index: RegistryIndexType = defaultdict(dict) self._config_entry_id_index: RegistryIndexType = defaultdict(dict) self._labels_index: RegistryIndexType = defaultdict(dict) + self._composite_device_id_index: RegistryIndexType = defaultdict(dict) @override def _index_entry(self, key: str, entry: DeviceEntry) -> None: @@ -778,8 +1126,9 @@ def _index_entry(self, key: str, entry: DeviceEntry) -> None: self._area_id_index[area_id][key] = True for label in entry.labels: self._labels_index[label][key] = True - for config_entry_id in entry.config_entries: - self._config_entry_id_index[config_entry_id][key] = True + self._config_entry_id_index[entry.config_entry_id][key] = True + if entry.composite_device_id is not None: + self._composite_device_id_index[entry.composite_device_id][key] = True @override def _unindex_entry( @@ -792,8 +1141,13 @@ def _unindex_entry( if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) - for config_entry_id in entry.config_entries: - self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index) + self._unindex_entry_value( + key, entry.config_entry_id, self._config_entry_id_index + ) + if entry.composite_device_id is not None: + self._unindex_entry_value( + key, entry.composite_device_id, self._composite_device_id_index + ) super()._unindex_entry(key, replacement_entry) def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]: @@ -815,12 +1169,98 @@ def get_devices_for_config_entry_id( data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) ] + def get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Get the devices a pre-migration composite device was split into.""" + data = self.data + return [ + data[key] + for key in self._composite_device_id_index.get(composite_device_id, ()) + ] + + +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, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + domain: str, + ) -> DeletedDeviceEntry | None: + """Return an orphan of the given domain to restore. + + Orphans are matched on their recorded domain so a chance identifier or connection + collision doesn't restore another integration's device. A domain-less orphan + (carried over by the migration with no recoverable domain) is left for the + periodic purge rather than restored. + """ + orphans: dict[str, DeletedDeviceEntry] = {} + for identifier in identifiers or (): + orphans.update(self._orphaned_identifiers.get(identifier, {})) + for connection in _normalize_connections(connections or set()): + orphans.update(self._orphaned_connections.get(connection, {})) + for entry in orphans.values(): + if entry.domain == domain: + return entry + return None + class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" devices: ActiveDeviceRegistryItems - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] + deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -842,8 +1282,53 @@ def async_get(self, device_id: str) -> DeviceEntry | None: We retrieve the DeviceEntry from the underlying dict to avoid the overhead of the UserDict __getitem__. + + For a pre-migration composite device id, a read-only composite device + merged from the split devices is returned, so integration code that resolves a + 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. """ - return self._device_data.get(device_id) + if (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): + return self._restore_composite_device(device_id, split_devices) + return None + + @callback + def _restore_composite_device( + self, device_id: str, split_devices: list[DeviceEntry] + ) -> DeviceEntry: + """Synthesize a read-only composite device from its split devices.""" + composite_subentries: dict[str, set[str | None]] = {} + identifiers: set[tuple[str, str]] = set() + connections: set[tuple[str, str]] = set() + for split_device in split_devices: + composite_subentries.setdefault(split_device.config_entry_id, set()).add( + split_device.config_subentry_id + ) + identifiers |= split_device.identifiers + connections |= split_device.connections + # Functional identity (identifiers, connections, serial_number) is consistent + # across splits of the same physical device. Use the split owning the composite's + # former primary config entry as the base, so config_entry_id - and thus + # primary_config_entry - reports the composite's former primary. + primary_config_entry = split_devices[0].composite_primary_config_entry + base = next( + ( + split_device + for split_device in split_devices + if split_device.config_entry_id == primary_config_entry + ), + split_devices[0], + ) + return attr.evolve( + base, + composite_subentries=composite_subentries, + connections=connections, # type: ignore[arg-type] + id=device_id, + identifiers=identifiers, # type: ignore[arg-type] + ) @callback def async_get_device( @@ -851,8 +1336,100 @@ def async_get_device( identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, ) -> DeviceEntry | None: - """Check if device is registered.""" - return self.devices.get_entry(identifiers, connections) + """Check if a device is registered. + + 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 + matches the looked-up identifier. If the remaining matches are the splits of one + pre-migration composite device, a read-only composite spanning them is returned + (async_update_device and async_remove_device fan it out to the underlying + devices). Otherwise, for independent devices sharing an identifier or connection, + one owned by the calling integration is preferred, falling back to the first + match. + """ + matches = self._async_matching_devices(identifiers, connections) + if len(matches) <= 1: + return matches[0] if matches else None + # If the matches are the splits of one pre-migration composite device, return a + # read-only composite over them, reusing the composite's id so stored references + # (an automation, a fired event, or an entity holding the old device id) keep + # resolving to it as before the split. + composite_device_ids = {match.composite_device_id for match in matches} + if ( + len(composite_device_ids) == 1 + and (pre_migration_id := next(iter(composite_device_ids))) is not None + ): + return self._restore_composite_device(pre_migration_id, matches) + # Otherwise they are independent devices sharing an identifier or connection. + # Prefer one owned by the calling integration so the caller resolves to its own + # device rather than an insertion-order-dependent one; fall back to the first. + if (domain := _current_integration_domain()) is not None and ( + device := self._first_device_in_domain(matches, domain) + ) is not None: + return device + return matches[0] + + def _first_device_in_domain( + self, devices: Iterable[DeviceEntry], domain: str + ) -> DeviceEntry | None: + """Return the first device whose config entry belongs to domain.""" + for device in devices: + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) + if entry is not None and entry.domain == domain: + return device + return None + + @callback + def _async_matching_devices( + self, + identifiers: AbstractSet[tuple[str, str]] | None, + connections: AbstractSet[tuple[str, str]] | None, + ) -> list[DeviceEntry]: + """Return devices matching the lookup, narrowed by identifier-domain priority.""" + matches = self.devices.get_entries(identifiers, connections) + if len(matches) > 1 and identifiers: + domains = {identifier[0] for identifier in identifiers} + preferred = [ + device + for device in matches + if ( + entry := self.hass.config_entries.async_get_entry( + device.config_entry_id + ) + ) + and entry.domain in domains + ] + if preferred: + return preferred + return matches + + @callback + def _async_device_ids_for_composite_device_id( + self, device_id: str + ) -> list[str] | None: + """Return the underlying real device ids if device_id is a composite.""" + if device_id in self.devices: + return None + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return [split_device.id for split_device in split_devices] + return None + + @callback + def async_get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Return the devices a composite device id represents. + + A composite device id is a pre-migration composite id - a device that belonged to + several config entries, split into one device per config entry, each keeping the + original id as composite_device_id. The underlying live devices are returned so + that actions and entity lookups targeting the composite id still reach all of + them; unmodified integrations keep the pre-rewrite behaviour, where a shared + identifier/connection resolved to a single multi-config-entry device. Returns an + empty list for a device id which is not a composite device id. + """ + return self.devices.get_devices_for_composite_device_id(composite_device_id) def _substitute_name_placeholders( self, @@ -908,7 +1485,10 @@ def async_get_or_create( sw_version: str | None | UndefinedType = UNDEFINED, translation_key: str | None = None, translation_placeholders: Mapping[str, str] | None = None, + # via_device is deprecated and will be removed in HA Core 2027.8, use + # via_device_id instead via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, + via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" default_manufacturer = _validate_str( @@ -931,6 +1511,22 @@ def async_get_or_create( f"Can't link device to unknown config entry {config_entry_id}" ) + # Validate before mutating the registry below. `via_device=None` (an explicit + # "no via device") alongside a via_device_id is contradictory, so reject it too. + if via_device is not UNDEFINED and via_device_id is not UNDEFINED: + raise HomeAssistantError( + "Passing both `via_device` and `via_device_id` is not allowed; " + "`via_device` is deprecated, pass `via_device_id` only" + ) + 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: full_translation_key = ( f"component.{config_entry.domain}.device.{translation_key}.name" @@ -958,6 +1554,7 @@ def async_get_or_create( ("name", name), ("suggested_area", suggested_area), ("via_device", via_device), + ("via_device_id", via_device_id), *validated_fields.items(), ) if val is not UNDEFINED @@ -974,7 +1571,9 @@ def async_get_or_create( connections = _normalize_connections(connections) device = self.devices.get_entry( - identifiers=identifiers, connections=connections + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, ) is_new = False @@ -982,7 +1581,20 @@ def async_get_or_create( if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry(identifiers, connections) + deleted_device = self.deleted_devices.get_entry( + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, + ) + if deleted_device is None: + # Fall back to an orphan (its owning config entry was removed) + # so re-adding an integration restores the device id, area, labels and name + # rather than create a fresh device. Matching on the recorded domain keeps + # a chance identifier/connection collision from restoring another + # integration's device. + deleted_device = self.deleted_devices.get_orphaned_entry( + identifiers, connections, config_entry.domain + ) if deleted_device is None: area_id: str | None = None if ( @@ -995,7 +1607,16 @@ def async_get_or_create( area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id - device = DeviceEntry(area_id=area_id) + device = DeviceEntry( + area_id=area_id, + config_entry_id=config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id=( + config_subentry_id + if config_subentry_id is not UNDEFINED + else None + ), + ) else: self.deleted_devices.pop(deleted_device.id) @@ -1024,7 +1645,22 @@ def async_get_or_create( name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.devices.get_entry(identifiers={via_device})) is None: + # Resolve the deprecated via_device to a device id. The identifier is not + # unique across config entries, so prefer a via device in the same config + # entry, then one from the same integration (domain), falling back to any + # config entry (a via device may legitimately belong to a different config + # entry). This ambiguity is why via_device is deprecated. + via = ( + self.devices.get_entry( + identifiers={via_device}, config_entry_id=config_entry_id + ) + or self._first_device_in_domain( + self.devices.get_entries(identifiers={via_device}), + config_entry.domain, + ) + or self.devices.get_entry(identifiers={via_device}) + ) + if via is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1032,25 +1668,46 @@ def async_get_or_create( core_behavior=ReportBehavior.LOG, breaks_in_ha_version="2025.12.0", ) - - via_device_id: str | UndefinedType = via.id if via else UNDEFINED + via_device_id = via.id if via else UNDEFINED + elif via_device is None: + # An explicit `via_device=None` means "no via device" (a via_device_id + # alongside it is rejected above). + via_device_id = None + + # On the owning integration's first re-registration of a device created by + # splitting a pre-migration composite device, replace the identifiers and + # connections copied from the composite with the ones the integration provides, + # instead of merging. This block and the has_composite_identifiers flag + # can be removed in HA Core 2027.8. + identifiers_connections: dict[str, Any] + has_composite_identifiers: bool | UndefinedType = UNDEFINED + if not is_new and device.has_composite_identifiers: + identifiers_connections = { + "new_connections": connections, + "new_identifiers": identifiers, + } + has_composite_identifiers = False else: - via_device_id = UNDEFINED + identifiers_connections = { + "merge_connections": connections or UNDEFINED, + "merge_identifiers": identifiers or UNDEFINED, + } device = self._async_update_device( device.id, allow_collisions=True, - add_config_entry_id=config_entry_id, - add_config_subentry_id=config_subentry_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, is_new=is_new, - merge_connections=connections or UNDEFINED, - merge_identifiers=identifiers or UNDEFINED, name=name, + has_composite_identifiers=has_composite_identifiers, + # Move the device if the integration re-registers it under a different + # subentry; UNDEFINED leaves the subentry unchanged. Also validates an + # explicitly provided subentry for new devices. + new_config_subentry_id=config_subentry_id, suggested_area=suggested_area, via_device_id=via_device_id, + **identifiers_connections, **validated_fields, ) @@ -1071,7 +1728,6 @@ def _async_update_device( # noqa: C901 allow_collisions: bool = False, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1084,6 +1740,10 @@ def _async_update_device( # noqa: C901 model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + # has_composite_identifiers can be removed in HA Core 2027.8 + has_composite_identifiers: bool | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1106,9 +1766,6 @@ def _async_update_device( # noqa: C901 new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs - config_entries = old.config_entries - config_entries_subentries = old.config_entries_subentries - if add_config_entry_id is not UNDEFINED: if ( add_config_entry := self.hass.config_entries.async_get_entry( @@ -1143,6 +1800,26 @@ def _async_update_device( # noqa: C901 "Can't remove config subentry without specifying config entry" ) + if ( + new_config_entry_id is not UNDEFINED + and self.hass.config_entries.async_get_entry(new_config_entry_id) is None + ): + raise HomeAssistantError( + f"Can't move device to unknown config entry {new_config_entry_id}" + ) + + if ( + new_config_entry_id is not UNDEFINED + or new_config_subentry_id is not UNDEFINED + ) and ( + add_config_entry_id is not UNDEFINED + or remove_config_entry_id is not UNDEFINED + ): + raise HomeAssistantError( + "Can't combine new_config_entry_id or new_config_subentry_id with " + "add_config_entry_id or remove_config_entry_id" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -1158,109 +1835,133 @@ def _async_update_device( # noqa: C901 "Cannot define both merge_identifiers and new_identifiers" ) - if add_config_entry_id is not UNDEFINED: - if add_config_subentry_id is UNDEFINED: - # Interpret not specifying a subentry as None (the main entry) - add_config_subentry_id = None - - primary_entry_id = old.primary_config_entry - if ( - device_info_type == "primary" - and add_config_entry_id != primary_entry_id - ): - if ( - primary_entry_id is None - or not ( - primary_entry := self.hass.config_entries.async_get_entry( - primary_entry_id - ) + # A device belongs to exactly one config entry and subentry: + # - add_config_entry_id (with an optional add_config_subentry_id) records a + # transient pending move to that config entry and subentry; on its own it does + # not move the device. Integrations move a device by adding the new config + # entry and then removing the current one, often in separate calls; the removal + # of the current config entry performs the pending move. + # - remove_config_entry_id on the owning entry performs a pending move if there + # is one, otherwise it removes the device, since it has no other config entry. + # - new_config_entry_id / new_config_subentry_id move the device immediately. + target_config_entry_id: str | UndefinedType = UNDEFINED + target_config_subentry_id: str | None | UndefinedType = UNDEFINED + pending_move: _PendingMove | None | UndefinedType = UNDEFINED + if new_config_entry_id is not UNDEFINED: + target_config_entry_id = new_config_entry_id + target_config_subentry_id = ( + new_config_subentry_id + if new_config_subentry_id is not UNDEFINED + else None + ) + # An immediate move to a new config entry supersedes a deferred move from an + # earlier add_config_entry_id; clear it so a later removal of the new owner + # deletes the device instead of performing the stale move. + pending_move = None + elif new_config_subentry_id is not UNDEFINED: + target_config_subentry_id = new_config_subentry_id + else: + if add_config_entry_id is not UNDEFINED: + # Adding the config entry (and subentry) the device already belongs to is a + # no-op; recording it as a pending move would make a later removal of that + # sole owner move the device to itself instead of deleting it. + already_owner = add_config_entry_id == old.config_entry_id and ( + add_config_subentry_id is UNDEFINED + or add_config_subentry_id == old.config_subentry_id + ) + if not already_owner: + pending_move = _PendingMove( + add_config_entry_id, + add_config_subentry_id + if add_config_subentry_id is not UNDEFINED + else None, + _current_integration_domain(), ) - or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS - ): - new_values["primary_config_entry"] = add_config_entry_id - old_values["primary_config_entry"] = primary_entry_id - - if add_config_entry_id not in old.config_entries: - config_entries = old.config_entries | {add_config_entry_id} - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: {add_config_subentry_id} - } - # Enable the device if it was disabled by config entry and we're adding - # a non disabled config entry + if remove_config_entry_id == old.config_entry_id and ( + remove_config_subentry_id is UNDEFINED + or remove_config_subentry_id == old.config_subentry_id + ): + move_from_prior_call = pending_move is UNDEFINED + move_target = ( + pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001 + ) + # A deferred move armed by an earlier add_config_entry_id only completes + # if the integration now removing the owning entry is the one that armed + # it. A removal from a different integration (e.g. device_tracker + # attaching a shared MAC) is unrelated, so cancel the move and delete the + # device instead of silently transferring it. Origins from core/tests are + # undetermined (None) and never cancel. if ( - # mypy says add_config_entry can be None. - # That's impossible, because we raise above if - # that happens - not add_config_entry.disabled_by # type: ignore[union-attr] - and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY + move_target is not None + and move_from_prior_call + and move_target.origin_domain is not None + and (current_domain := _current_integration_domain()) is not None + and current_domain != move_target.origin_domain ): - new_values["disabled_by"] = None - old_values["disabled_by"] = old.disabled_by - elif ( - add_config_subentry_id - not in old.config_entries_subentries[add_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: old.config_entries_subentries[ - add_config_entry_id - ] - | {add_config_subentry_id} - } - - if ( - remove_config_entry_id is not UNDEFINED - and remove_config_entry_id in config_entries - ): - if remove_config_subentry_id is UNDEFINED: - config_entries_subentries = dict(old.config_entries_subentries) - del config_entries_subentries[remove_config_entry_id] - elif ( - remove_config_subentry_id - in old.config_entries_subentries[remove_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - remove_config_entry_id: old.config_entries_subentries[ - remove_config_entry_id - ] - - {remove_config_subentry_id} - } - if not config_entries_subentries[remove_config_entry_id]: - del config_entries_subentries[remove_config_entry_id] - - if remove_config_entry_id not in config_entries_subentries: - if config_entries == {remove_config_entry_id}: + move_target = None + if move_target is None: self.async_remove_device(device_id) return None - - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry - - config_entries = config_entries - {remove_config_entry_id} - - # Disable the device if it is enabled and all remaining config entries - # are disabled - has_enabled_config_entries = any( - config_entry.disabled_by is None - for config_entry_id in config_entries - if ( - config_entry := self.hass.config_entries.async_get_entry( - config_entry_id - ) - ) - is not None + target_config_entry_id = move_target.config_entry_id + target_config_subentry_id = move_target.config_subentry_id + pending_move = None + # 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. + if old.composite_device_id is not None: + for sibling in self.devices.get_devices_for_composite_device_id( + old.composite_device_id + ): + if ( + sibling.id != device_id + and sibling._pending_move is not None # noqa: SLF001 + ): + self.devices[sibling.id] = attr.evolve( + sibling, pending_move=None + ) + + if target_config_subentry_id not in (UNDEFINED, None): + resolved_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) + resolved_config_entry = self.hass.config_entries.async_get_entry( + resolved_config_entry_id + ) + if ( + resolved_config_entry is None + or target_config_subentry_id not in resolved_config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {resolved_config_entry_id} has no" + f" subentry {target_config_subentry_id}" ) - if not has_enabled_config_entries and old.disabled_by is None: - new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY - old_values["disabled_by"] = old.disabled_by - - if config_entries != old.config_entries: - new_values["config_entries"] = config_entries - old_values["config_entries"] = old.config_entries - if config_entries_subentries != old.config_entries_subentries: - new_values["config_entries_subentries"] = config_entries_subentries - old_values["config_entries_subentries"] = old.config_entries_subentries + if ( + target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + ): + new_values["config_entry_id"] = target_config_entry_id + old_values["config_entry_id"] = old.config_entry_id + if ( + target_config_subentry_id is not UNDEFINED + and target_config_subentry_id != old.config_subentry_id + ): + new_values["config_subentry_id"] = target_config_subentry_id + old_values["config_subentry_id"] = old.config_subentry_id + # pending_move is a transient runtime-only attribute; it is not reported in the + # update event (not added to old_values) and never stored + if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001 + new_values["pending_move"] = pending_move + + # Identifiers and connections are unique per config entry, so when the device is + # moved to another config entry they are validated against the new one + effective_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1268,6 +1969,7 @@ def _async_update_device( # noqa: C901 if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, + effective_config_entry_id, merge_connections, allow_collisions, ) @@ -1279,7 +1981,10 @@ def _async_update_device( # noqa: C901 if merge_identifiers is not UNDEFINED: merge_identifiers = self._validate_identifiers( - device_id, merge_identifiers, allow_collisions + device_id, + effective_config_entry_id, + merge_identifiers, + allow_collisions, ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): @@ -1289,16 +1994,52 @@ def _async_update_device( # noqa: C901 if new_connections is not UNDEFINED: added_connections = new_values["connections"] = self._validate_connections( - device_id, new_connections, False + device_id, effective_config_entry_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: added_identifiers = new_values["identifiers"] = self._validate_identifiers( - device_id, new_identifiers, False + device_id, effective_config_entry_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers + # On a move to another config entry, validate the identifiers and connections + # retained from the old entry against the new one, so the move can't silently + # overwrite the index slot of a device that already has the same identity there. + # A full new_identifiers / new_connections replacement is validated above; + # merge_* only adds, so the retained old values still need checking here. + if effective_config_entry_id != old.config_entry_id: + if new_identifiers is UNDEFINED: + self._validate_identifiers( + device_id, effective_config_entry_id, old.identifiers, False + ) + if new_connections is UNDEFINED: + self._validate_connections( + device_id, effective_config_entry_id, old.connections, False + ) + + # On a move, reflect the new owning config entry's disabled state (as restoring a + # deleted device does) unless 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. + if ( + disabled_by is UNDEFINED + and target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + and ( + target_entry := self.hass.config_entries.async_get_entry( + target_config_entry_id + ) + ) + is not None + ): + if target_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 + for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), @@ -1311,6 +2052,7 @@ def _async_update_device( # noqa: C901 ("model_id", model_id), ("name", name), ("name_by_user", name_by_user), + ("has_composite_identifiers", has_composite_identifiers), ("serial_number", serial_number), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -1336,13 +2078,28 @@ def _async_update_device( # noqa: C901 new = attr.evolve(old, **new_values) self.devices[device_id] = new - # NOTE: Once we solve the broader issue of duplicated devices, we might - # want to revisit it. Instead of simply removing the duplicated deleted device, - # we might want to merge the information from it into the non-deleted device. + # On a move, the device's whole retained identity newly appears in the target + # config entry; added_identifiers/added_connections are empty on a retained- + # identity move, so match the target entry's deleted device by the full identity. + match_identifiers: set[tuple[str, str]] | None + match_connections: set[tuple[str, str]] | None + if effective_config_entry_id != old.config_entry_id: + match_identifiers = new.identifiers + match_connections = new.connections + else: + match_identifiers = added_identifiers + match_connections = added_connections for deleted_device in self.deleted_devices.get_entries( - added_identifiers, added_connections + match_identifiers, match_connections ): - del self.deleted_devices[deleted_device.id] + # get_entries matches across config entries, but identifiers/connections are + # unique per config entry - only remove the deleted device owned by this + # device's config entry, so another entry can still restore its own. + if ( + deleted_device.config_entry_id == effective_config_entry_id + and deleted_device.id in self.deleted_devices + ): + del self.deleted_devices[deleted_device.id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -1374,7 +2131,6 @@ def async_update_device( add_config_subentry_id: str | None | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1386,6 +2142,8 @@ def async_update_device( model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1398,11 +2156,57 @@ def async_update_device( ) -> DeviceEntry | None: """Update device attributes. - :param add_config_subentry_id: Add the device to a specific - subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a - specific subentry of remove_config_entry_id + 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, pass remove_config_entry_id with the + device's config entry. + + :param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it + moves the device; on its own it does nothing. + :param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id + it moves the device to another subentry; on its own it does nothing. + :param new_config_entry_id: Move the device to this config entry. + :param new_config_subentry_id: Move the device to this subentry. + :param remove_config_entry_id: Remove the device if it is the device's config + entry, unless combined with add_config_entry_id to move the device. + :param remove_config_subentry_id: Remove the device from a specific subentry of + remove_config_entry_id. """ + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + # Fan the update out to each underlying device; keep in sync with the + # update parameters above. + update_args = { + "add_config_entry_id": add_config_entry_id, + "add_config_subentry_id": add_config_subentry_id, + "area_id": area_id, + "configuration_url": configuration_url, + "disabled_by": disabled_by, + "entry_type": entry_type, + "hw_version": hw_version, + "labels": labels, + "manufacturer": manufacturer, + "merge_connections": merge_connections, + "merge_identifiers": merge_identifiers, + "model": model, + "model_id": model_id, + "name_by_user": name_by_user, + "name": name, + "new_config_entry_id": new_config_entry_id, + "new_config_subentry_id": new_config_subentry_id, + "new_connections": new_connections, + "new_identifiers": new_identifiers, + "remove_config_entry_id": remove_config_entry_id, + "remove_config_subentry_id": remove_config_subentry_id, + "serial_number": serial_number, + "suggested_area": suggested_area, + "sw_version": sw_version, + "via_device_id": via_device_id, + } + return self._async_update_composite_device( + device_id, underlying_ids, update_args + ) if suggested_area is not UNDEFINED: report_usage( "passes a suggested_area to device_registry.async_update device", @@ -1425,7 +2229,6 @@ def async_update_device( add_config_entry_id=add_config_entry_id, add_config_subentry_id=add_config_subentry_id, area_id=area_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, labels=labels, @@ -1433,6 +2236,8 @@ def async_update_device( merge_identifiers=merge_identifiers, name_by_user=name_by_user, name=name, + new_config_entry_id=new_config_entry_id, + new_config_subentry_id=new_config_subentry_id, new_connections=new_connections, new_identifiers=new_identifiers, remove_config_entry_id=remove_config_entry_id, @@ -1446,10 +2251,15 @@ def async_update_device( def _validate_connections( self, device_id: str, + config_entry_id: str, connections: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Normalize and validate connections, raise on collision with other devices.""" + """Normalize and validate connections, raise on collision with other devices. + + Connections are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ normalized_connections = _normalize_connections(connections) if allow_collisions: return normalized_connections @@ -1459,7 +2269,9 @@ def _validate_connections( # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(connections={connection}) + existing_device := self.devices.get_entry( + connections={connection}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1471,10 +2283,15 @@ def _validate_connections( def _validate_identifiers( self, device_id: str, + config_entry_id: str, identifiers: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Validate identifiers, raise on collision with other devices.""" + """Validate identifiers, raise on collision with other devices. + + Identifiers are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ if allow_collisions: return identifiers @@ -1483,21 +2300,70 @@ def _validate_identifiers( # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(identifiers={identifier}) + existing_device := self.devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) return identifiers + @callback + def _async_update_composite_device( + self, + composite_id: str, + underlying_ids: list[str], + update_args: dict[str, Any], + ) -> DeviceEntry | None: + """Fan an async_update_device call on a composite out to its real devices.""" + forward = { + name: value for name, value in update_args.items() if value is not UNDEFINED + } + if ignored := [ + name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward + ]: + # These rewrite a device's functional identity or move it, which is ambiguous + # across the composite's underlying devices; drop them rather than corrupt or + # collide, and report the offending integration. + report_usage( + f"passed {', '.join(ignored)} to device_registry.async_update_device " + "for a composite device that spans several config entries (returned for " + "an ambiguous async_get_device lookup, or " + "resolved from a stored device id of a pre-migration composite); the " + "argument cannot be applied to the merged device and was ignored - " + "target a single device, e.g. one returned by " + "async_entries_for_config_entry", + core_behavior=ReportBehavior.LOG, + ) + for name in ignored: + del forward[name] + for underlying_id in underlying_ids: + self.async_update_device(underlying_id, **forward) + remaining = [ + self.devices[underlying_id] + for underlying_id in underlying_ids + if underlying_id in self.devices + ] + if not remaining: + return None + return self._restore_composite_device(composite_id, remaining) + @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + for underlying_id in underlying_ids: + self.async_remove_device(underlying_id) + return self.hass.verify_event_loop_thread("device_registry.async_remove_device") 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( area_id=device.area_id, - config_entries=device.config_entries, - config_entries_subentries=device.config_entries_subentries, + config_entry_id=device.config_entry_id, + config_subentry_id=device.config_subentry_id, connections=device.connections, created_at=device.created_at, disabled_by=device.disabled_by, @@ -1507,6 +2373,7 @@ def async_remove_device(self, device_id: str) -> None: modified_at=utcnow(), name_by_user=device.name_by_user, orphaned_timestamp=None, + domain=config_entry.domain if config_entry is not None else None, ) for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: @@ -1530,19 +2397,14 @@ async def _async_load(self) -> None: data = await self._store.async_load() devices = ActiveDeviceRegistryItems() - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems() + deleted_devices = DeletedDeviceRegistryItems() if data is not None: for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries_subentries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1567,13 +2429,22 @@ async def _async_load(self) -> None: for iden in device["identifiers"] }, labels=set(device["labels"]), + composite_device_id=device["composite_device_id"], + composite_primary_config_entry=device[ + "composite_primary_config_entry" + ], + split_at=( + datetime.fromisoformat(device["split_at"]) + if device["split_at"] + else None + ), manufacturer=device["manufacturer"], model=device["model"], model_id=device["model_id"], modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], name=device["name"], - primary_config_entry=device["primary_config_entry"], + has_composite_identifiers=device["has_composite_identifiers"], serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], @@ -1596,13 +2467,8 @@ def get_optional_enum[_EnumT: StrEnum]( for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), disabled_by=get_optional_enum( @@ -1616,6 +2482,7 @@ def get_optional_enum[_EnumT: StrEnum]( modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], orphaned_timestamp=device["orphaned_timestamp"], + domain=device["domain"], ) self.devices = devices @@ -1645,83 +2512,110 @@ def _data_to_save(self) -> dict[str, Any]: } @callback - def async_clear_config_entry(self, config_entry_id: str) -> None: + def _resolve_orphan_domain( + self, config_entry_id: str, domain: str | None + ) -> str | None: + """Return the domain to record on devices orphaned from a config entry.""" + if domain is not None: + return domain + if ( + entry := self.hass.config_entries.async_get_entry(config_entry_id) + ) is not None: + return entry.domain + return None + + @callback + def _async_orphan_deleted_device( + self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float + ) -> None: + """Mark a deleted device as orphaned, remembering its former domain.""" + if domain is not None: + # Orphans are indexed by their recorded domain, so two orphans of the + # same domain sharing an identifier or connection would collide. When a + # device from the same integration is orphaned, drop any existing orphan + # it overlaps so the newest one wins deterministically instead of shadowing + # it. + for existing in list(self.deleted_devices.values()): + if ( + existing.config_entry_id is None + and existing.domain == domain + and ( + existing.connections & deleted_device.connections + or existing.identifiers & deleted_device.identifiers + ) + ): + del self.deleted_devices[existing.id] + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entry_id=None, + config_subentry_id=None, + orphaned_timestamp=now_time, + domain=domain, + ) + self.async_schedule_save() + + @callback + def async_clear_config_entry( + self, config_entry_id: str, domain: str | None = None + ) -> None: """Clear config entry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): - self._async_update_device(device.id, remove_config_entry_id=config_entry_id) + self.async_remove_device(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. + for device in list(self.devices.values()): + if device.composite_primary_config_entry == config_entry_id: + self.devices[device.id] = attr.evolve( + device, composite_primary_config_entry=None + ) + self.async_schedule_save() + # A device owned by another config entry may hold a transient pending move + # targeting the entry being removed; clear it so a later completion deletes the + # device instead of moving it onto the removed entry. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 + if ( + pending_move is not None + and pending_move.config_entry_id == config_entry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - if config_entry_id not in config_entries: + if deleted_device.config_entry_id != config_entry_id: continue - if config_entries == {config_entry_id}: - # Add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries = config_entries - {config_entry_id} - config_entries_subentries = dict( - deleted_device.config_entries_subentries - ) - del config_entries_subentries[config_entry_id] - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_clear_config_subentry( - self, config_entry_id: str, config_subentry_id: str + self, config_entry_id: str, config_subentry_id: str, domain: str | None = None ) -> None: - """Clear config entry from registry entries.""" + """Clear config subentry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): - self._async_update_device( - device.id, - remove_config_entry_id=config_entry_id, - remove_config_subentry_id=config_subentry_id, - ) + if device.config_subentry_id != config_subentry_id: + continue + self.async_remove_device(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. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 + if ( + pending_move is not None + and pending_move.config_entry_id == config_entry_id + and pending_move.config_subentry_id == config_subentry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - config_entries_subentries = deleted_device.config_entries_subentries if ( - config_entry_id not in config_entries_subentries - or config_subentry_id not in config_entries_subentries[config_entry_id] + deleted_device.config_entry_id != config_entry_id + or deleted_device.config_subentry_id != config_subentry_id ): continue - if config_entries_subentries == {config_entry_id: {config_subentry_id}}: - # We're removing the last config subentry from the last config - # entry, add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries_subentries = config_entries_subentries | { - config_entry_id: config_entries_subentries[config_entry_id] - - {config_subentry_id} - } - if not config_entries_subentries[config_entry_id]: - del config_entries_subentries[config_entry_id] - config_entries = config_entries - {config_entry_id} - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_purge_expired_orphaned_devices(self) -> None: @@ -1821,7 +2715,6 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DeviceEntryDisabler.CONFIG_ENTRY. - Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -1833,25 +2726,37 @@ def async_config_entry_disabled_by_changed( registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 return - enabled_config_entries = { - entry.entry_id - for entry in registry.hass.config_entries.async_entries() - if not entry.disabled_by - } - for device in devices: if device.disabled: # Device already disabled, do not overwrite continue - if len(device.config_entries) > 1 and device.config_entries.intersection( - enabled_config_entries - ): - continue registry._async_update_device( # noqa: SLF001 device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY ) +@callback +def _migrate_device_disabled_by( + device: dict[str, Any], config_entry_disabled: bool +) -> None: + """Reconcile a stored device's disabled_by with its config entry's disabled state. + + Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13 + migration can fix a split device that inherited the composite's disabled_by. Kept in + lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be + removed in HA Core 2027.8. + """ + disabled_by = device["disabled_by"] + if not config_entry_disabled: + # Config entry enabled: drop a config-entry disable, keep a user/integration one + if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + device["disabled_by"] = None + return + # Config entry disabled: disable the device unless it is already disabled + if disabled_by is None: + device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY + + @callback def async_cleanup( hass: HomeAssistant, @@ -1864,8 +2769,7 @@ def async_cleanup( references_config_entries = { device.id for device in dev_reg.devices.values() - for config_entry_id in device.config_entries - if config_entry_id in config_entry_ids + if device.config_entry_id in config_entry_ids } # Find all devices that are referenced in the entity registry. @@ -1883,11 +2787,10 @@ def async_cleanup( # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( for device in list(dev_reg.devices.values()): - for config_entry_id in device.config_entries: - if config_entry_id not in config_entry_ids: - dev_reg._async_update_device( # noqa: SLF001 - device.id, remove_config_entry_id=config_entry_id - ) + if device.config_entry_id not in config_entry_ids: + dev_reg._async_update_device( # noqa: SLF001 + device.id, remove_config_entry_id=device.config_entry_id + ) # Periodic purge of orphaned devices to avoid the registry # growing without bounds when there are lots of deleted devices diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3683385f7b0a07..9d3cd41e329f53 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): Also maintains a count of enabled entries per config entry id. """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + # hass is stored only so get_entries_for_device_id can expand a pre-migration + # composite device id to its split devices. Remove it, and restore the no-argument + # constructor, once the device registry deprecation period is over and composite + # device ids are no longer resolved. + self._hass = hass self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} self._config_entry_id_index: RegistryIndexType = defaultdict(dict) @@ -1002,13 +1007,44 @@ def get_entry(self, key: str) -> RegistryEntry | None: return self._entry_ids.get(key) def get_entries_for_device_id( - self, device_id: str, include_disabled_entities: bool = False + self, + device_id: str, + include_disabled_entities: bool = False, ) -> list[RegistryEntry]: - """Get entries for device.""" + """Get entries for device. + + A device_id may be a pre-migration composite device id, which was split into one + device per config entry. The entries of the split devices are included, so a + lookup by the old composite id still finds the entities that were moved to the + split devices. + """ data = self.data + device_registry = dr.async_get(self._hass) + if device_id in device_registry.devices: + # Fast path: a live device id resolves directly to its own entities + return [ + entry + for key in self._device_id_index.get(device_id, ()) + if not (entry := data[key]).disabled_by or include_disabled_entities + ] + # A pre-migration composite device id resolves to the entities of the split + # devices it was migrated into. device_id is kept in the list because the slow + # path is also hit for a device that was just removed (no longer in + # device_registry.devices) whose entities still need to be found - e.g. when the + # entity registry prunes the entities of a removed device. + device_ids = [ + device_id, + *( + device.id + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ), + ] return [ entry - for key in self._device_id_index.get(device_id, ()) + for a_device_id in device_ids + for key in self._device_id_index.get(a_device_id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] @@ -1629,36 +1665,32 @@ def async_device_modified( changes = event.data["changes"] - # Remove entities which belong to config entries no longer associated with the - # device - if old_config_entries := changes.get("config_entries"): + # Remove entities which belong to the config entry the device no longer belongs + # to. changes carries the old config_entry_id only when it changed (a move). + if "config_entry_id" in changes: + old_config_entry_id = changes["config_entry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id if ( - entity.config_entry_id in old_config_entries - and entity.config_entry_id not in device.config_entries + entity.config_entry_id == old_config_entry_id + and entity.config_entry_id != device.config_entry_id ): self.async_remove(entity.entity_id) - # Remove entities which belong to config subentries no longer - # associated with the device - if old_config_entries_subentries := changes.get("config_entries_subentries"): + # Remove entities which belong to the config subentry the device no longer + # belongs to. changes carries the old config_subentry_id only when it changed. + if "config_subentry_id" in changes: + old_config_subentry_id = changes["config_subentry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id - config_subentry_id = entity.config_subentry_id if ( - config_entry_id in device.config_entries - and config_entry_id in old_config_entries_subentries - and config_subentry_id - in old_config_entries_subentries[config_entry_id] - and config_subentry_id - not in device.config_entries_subentries[config_entry_id] + entity.config_entry_id == device.config_entry_id + and entity.config_subentry_id == old_config_subentry_id + and entity.config_subentry_id != device.config_subentry_id ): self.async_remove(entity.entity_id) @@ -2011,16 +2043,53 @@ def async_update_entity_options( async def _async_load(self) -> None: """Load the entity registry.""" # Device registry must be loaded before entity registry because - # migration and entity processing reference device names. - await dr.async_get(self.hass).async_wait_loaded() + # migration and entity processing reference device names, and because entities + # are moved to the correct device when a pre-migration composite device was + # split into one device per config entry. + device_registry = dr.async_get(self.hass) + await device_registry.async_wait_loaded() _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) data = await self._store.async_load() - entities = EntityRegistryItems() + entities = EntityRegistryItems(self.hass) deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} + # Move entities to the correct device when a pre-migration composite device was + # split into one device per config entry. This can be removed 12 months after + # the config entries split migration ships. + migrated_composite_device = False + + def _split_device_id( + device_id: str | None, + config_entry_id: str | None, + config_subentry_id: str | None, + ) -> 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: + return device_id + successors = device_registry.async_get_devices_for_composite_device_id( + device_id + ) + if not successors: + # The device is gone (e.g. the migration dropped a device with no config + # entry) and was not split; detach the entity rather than leave it pointing + # at a device id that no longer exists. + return None + for successor in successors: + if ( + successor.config_entry_id == config_entry_id + and successor.config_subentry_id == config_subentry_id + ): + return successor.id + for successor in successors: + if successor.config_entry_id == config_entry_id: + return successor.id + return successors[0].id + if data is not None: for entity in data["entities"]: try: @@ -2048,11 +2117,19 @@ async def _async_load(self) -> None: ) continue + device_id = _split_device_id( + entity["device_id"], + entity["config_entry_id"], + entity["config_subentry_id"], + ) + if device_id != entity["device_id"]: + migrated_composite_device = True + original_name_unprefixed = _unprefix_original_name( self.hass, entity["original_name"], entity["has_entity_name"], - entity["device_id"], + device_id, ) entities[entity["entity_id"]] = RegistryEntry( @@ -2065,7 +2142,7 @@ async def _async_load(self) -> None: config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - device_id=entity["device_id"], + device_id=device_id, disabled_by=RegistryEntryDisabler(entity["disabled_by"]) if entity["disabled_by"] else None, @@ -2164,6 +2241,10 @@ def get_optional_enum[_EnumT: StrEnum]( self.entities = entities self._entities_data = entities.data + # Persist entities moved off a split pre-migration composite device + if migrated_composite_device: + self.async_schedule_save() + @override def _data_to_save(self) -> dict[str, Any]: """Return data of entity registry to store in a file.""" @@ -2300,7 +2381,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: - """Return entries that match a device.""" + """Return entries that match a device. + + A pre-migration composite device id resolves to the entries of the devices it was + split into. + """ return registry.entities.get_entries_for_device_id( device_id, include_disabled_entities ) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 87eb7041699b56..d34151002f113e 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids( selected.missing_areas.add(area_id) for device_id in target_selection.device_ids: - if device_id not in dev_reg.devices: + 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) if target_selection.label_ids: label_reg = lr.async_get(hass) @@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids( ) selected.referenced_areas.update(target_selection.area_ids) - selected.referenced_devices.update(target_selection.device_ids) if not selected.referenced_areas and not selected.referenced_devices: return selected diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 8ca2ef7fef11aa..173d792ba6e3bc 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -11,6 +11,7 @@ from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 525201f80b7502..635bbca5961460 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -300,6 +300,8 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) diff --git a/requirements_all.txt b/requirements_all.txt index 19eddda8eddf86..e0f940c1b7a680 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1380,7 +1380,7 @@ inkbird-ble==1.4.4 insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire -intellifire4py==4.4.0 +intellifire4py==4.5.0 # homeassistant.components.iometer iometer==1.0.2 @@ -3311,7 +3311,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.7.0 +victron-mqtt==2026.7.4 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index cb96c9396c2bdb..df30a4b766dc75 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: }, ) device_registry = mock_device_registry( - hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")} + hass, + { + "mock-dev-id": DeviceEntry( + config_entry_id="mock-config-entry", + id="mock-dev-id", + area_id="mock-area-id", + ) + }, ) policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}} diff --git a/tests/common.py b/tests/common.py index 474863bfaa1768..60000058bff418 100644 --- a/tests/common.py +++ b/tests/common.py @@ -292,6 +292,7 @@ def async_create_task_internal(coroutine, name=None, eager_start=True): ) }, ) + hass.config_entries._initialized.set() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hass.config_entries._async_shutdown, @@ -677,7 +678,7 @@ def mock_registry( if mock_entries is None: mock_entries = {} registry.deleted_entities = {} - registry.entities = er.EntityRegistryItems() + registry.entities = er.EntityRegistryItems(hass) registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry @@ -763,7 +764,7 @@ def mock_device_registry( mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = dr.DeviceRegistryItems() + registry.deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 1a500da5ea8509..7d63e61a3aa150 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -158,7 +158,9 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters( """Test invalid info skill service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -278,21 +282,21 @@ async def test_config_entry_not_loaded( async def test_invalid_config_entry( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that a non-existing entry ID in device config entries is skipped.""" - - await setup_integration(hass, mock_config_entry) + """Test that a device pointing to a non-existing config entry ID is skipped.""" - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + device_entry = dr.DeviceEntry( + config_entry_id="non_existing_entry_id", + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) - assert device_entry - - device_entry.config_entries.clear() - device_entry.config_entries.add("non_existing_entry_id") + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( @@ -300,14 +304,14 @@ async def test_invalid_config_entry( "send_sound", { ATTR_SOUND: "bell_02", - ATTR_DEVICE_ID: device_entry.id, + ATTR_DEVICE_ID: TEST_DEVICE_1_ID, }, blocking=True, ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device_entry.id} + assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID} async def test_missing_config_entry( @@ -316,7 +320,7 @@ async def test_missing_config_entry( mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test missing config entry.""" + """Test that a device not owned by an Alexa config entry is rejected.""" await setup_integration(hass, mock_config_entry) @@ -325,7 +329,15 @@ async def test_missing_config_entry( ) assert device_entry - device_entry.config_entries.clear() + # Move the device to a config entry from a different integration + other_entry = MockConfigEntry(domain="other_domain", data={}) + other_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=other_entry.entry_id + ) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=mock_config_entry.entry_id + ) # Call Service with pytest.raises(ServiceValidationError) as exc_info: diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 3c1505ff54f3f4..7a2b1379dcb661 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dcd7b1faa83596..2864c85a3ddb11 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -331,10 +331,14 @@ def target_calendars( label_on_devices = label_registry.async_create("label_on_devices") device_calendar_1 = dr.DeviceEntry( - id="device_calendar_1", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_1", + labels=[label_on_devices.label_id], ) device_calendar_2 = dr.DeviceEntry( - id="device_calendar_2", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_2", + labels=[label_on_devices.label_id], ) mock_device_registry( hass, diff --git a/tests/components/common.py b/tests/components/common.py index c8e0a869aa4d6d..98087ae1e18df5 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -90,7 +90,12 @@ async def target_entities( "Test Label" ) - device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id}) + device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="test_device", + area_id=area.id, + labels={label.label_id}, + ) mock_device_registry(hass, {device.id: device}) entity_reg = er.async_get(hass) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 4c0f5f18e3bc8b..153d4f5c685fc1 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -61,6 +61,8 @@ async def test_list_devices( "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": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -84,6 +86,8 @@ async def test_list_devices( "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": utcnow().timestamp(), @@ -119,6 +123,8 @@ class Unserializable: "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": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -307,7 +313,7 @@ async def async_remove_config_entry_device( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -315,11 +321,14 @@ async def async_remove_config_entry_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -327,26 +336,21 @@ async def async_remove_config_entry_device( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } + async def test_remove_config_entry_from_device_fails( hass: HomeAssistant, @@ -396,38 +400,38 @@ async def async_remove_config_entry_device( entry_3.supports_remove_device = True entry_3.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - entry_1.entry_id, - entry_2.entry_id, - entry_3.entry_id, - } + # Identifiers and connections are unique per config entry, so each config entry + # gets its own device even though they share a connection + assert device_entry_1.config_entries == {entry_1.entry_id} + assert device_entry_2.config_entries == {entry_2.entry_id} + assert device_entry_3.config_entries == {entry_3.entry_id} fake_entry_id = "abc123" assert entry_1.entry_id != fake_entry_id fake_device_id = "abc123" - assert device_entry.id != fake_device_id + assert device_entry_3.id != fake_device_id # Try removing a non existing config entry from the device - response = await ws_client.remove_device(device_entry.id, fake_entry_id) + response = await ws_client.remove_device(device_entry_3.id, fake_entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -443,22 +447,22 @@ async def async_remove_config_entry_device( assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) - - assert response["success"] - assert set(response["result"]["config_entries"]) == { - entry_1.entry_id, - entry_3.entry_id, - } - - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" + # Removing a config entry which supports removal removes the device, since it is + # the device's only config entry + response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id) + + assert response["success"] + assert response["result"] is None + assert not device_registry.async_get(device_entry_2.id) + # Try removing a config entry which can't be loaded from a device - allowed - response = await ws_client.remove_device(device_entry.id, entry_3.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -517,7 +521,7 @@ async def async_remove_config_entry_device( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -525,11 +529,14 @@ async def async_remove_config_entry_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -537,22 +544,17 @@ async def async_remove_config_entry_device( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index f5330670ddd00a..0208c1e9dce164 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, derivative_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the derivative config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source device is not removed when the source entity is removed.""" assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() @@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.derivative.async_unload_entry", wraps=derivative.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") assert derivative_entity_entry.device_id is None - # Check that the derivative config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed @@ -380,7 +370,7 @@ async def test_migration_1_2( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.2 removes derivative config entry from device.""" + """Test migration from v1.2 keeps the derivative entity linked to the source device.""" derivative_config_entry = MockConfigEntry( data={}, @@ -399,22 +389,13 @@ async def test_migration_1_2( ) derivative_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() assert derivative_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the derivative config entry is not on the source device and the + # derivative entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d54da57b38afc7..367a327b81a750 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,5 +1,6 @@ """The test for light device automation.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import attr @@ -11,14 +12,23 @@ from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import ( DOMAIN, + DeviceAutomationType, InvalidDeviceAutomationConfig, toggle_entity, ) +from homeassistant.components.device_automation.helpers import ( + _resolve_device_id, + async_validate_device_automation_config, +) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall -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.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound from homeassistant.requirements import RequirementsNotFound @@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions( await device_automation.async_get_device_automation_platform( hass, "test", device_automation.DeviceAutomationType.TRIGGER ) + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_device_automation_resolves_legacy_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device automation legacy id resolves to the split owning its domain's entry. + + Automations for an entity platform domain are left as the composite id, which the + restored composite device and async_entries_for_device handle directly. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + await ar.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + + # A config-entry domain resolves to the split owning that domain's config entry + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id] + ) + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id] + ) + + # An entity platform domain is left unresolved, even when a split has such entities + entity_registry.async_get_or_create( + "light", + "domain_a", + "unique", + config_entry=entry_a, + device_id=by_entry[entry_a.entry_id], + ) + assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID + + # An unknown domain is returned unchanged + assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID + + +async def test_validate_config_rewrites_composite_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration: None, +) -> None: + """Validating a device automation rewrites a composite id to its domain's split.""" + fake_entry = MockConfigEntry(domain="fake_integration") + fake_entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_fake = device_registry.async_get_or_create( + config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")} + ) + device_other = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "1")} + ) + entity = entity_registry.async_get_or_create( + "light", "fake_integration", "u", device_id=device_fake.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_fake.id] = attr.evolve( + device_fake, composite_device_id=old_id + ) + device_registry.devices[device_other.id] = attr.evolve( + device_other, composite_device_id=old_id + ) + assert old_id not in device_registry.devices + + validated = await async_validate_device_automation_config( + hass, + { + "platform": "device", + "domain": "fake_integration", + "device_id": old_id, + "entity_id": entity.entity_id, + "type": "turned_on", + }, + vol.Schema( + {vol.Required("device_id"): str, vol.Required("domain"): str}, + extra=vol.ALLOW_EXTRA, + ), + DeviceAutomationType.TRIGGER, + ) + assert validated["device_id"] == device_fake.id diff --git a/tests/components/diagnostics/test_util.py b/tests/components/diagnostics/test_util.py index 6f1c1b2e199563..004d4d6f904e99 100644 --- a/tests/components/diagnostics/test_util.py +++ b/tests/components/diagnostics/test_util.py @@ -5,8 +5,10 @@ from homeassistant.components.diagnostics import ( REDACTED, async_redact_data, + device_entry_as_dict, entity_entry_as_dict, ) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -88,3 +90,35 @@ def test_entity_entry_as_dict() -> None: assert result["original_name"] == "Test Sensor" assert result["supported_features"] == 0 assert result["created_at"] == created + + +def test_device_entry_as_dict() -> None: + """Test device_entry_as_dict.""" + created = datetime.fromisoformat("2024-01-01T00:00:00+00:00") + entry = DeviceEntry( + config_entry_id="mock-config-entry-id", + created_at=created, + identifiers={("test", "unique123")}, + modified_at=created, + name="Test Device", + ) + + result = device_entry_as_dict(entry) + + assert isinstance(result, dict) + # Internal bookkeeping and composite-device migration attributes are excluded + for attribute in ( + "_cache", + "_composite_subentries", + "_pending_move", + "_suggested_area", + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", + ): + assert attribute not in result + assert result["config_entry_id"] == "mock-config-entry-id" + assert result["identifiers"] == [["test", "unique123"]] + assert result["name"] == "Test Device" + assert result["created_at"] == created diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index b27e00a747cdc3..dee465efea12ca 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -30,14 +30,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -57,7 +51,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -284,14 +277,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -311,7 +298,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -944,14 +930,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -971,7 +951,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -1198,14 +1177,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1225,7 +1198,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -1918,14 +1890,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1945,7 +1911,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -2172,14 +2137,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2199,7 +2158,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -2921,14 +2879,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2948,7 +2900,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -3491,14 +3442,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ list([ @@ -3522,7 +3467,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -3844,14 +3788,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -3871,7 +3809,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -4414,14 +4351,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4441,7 +4372,6 @@ 'model_id': None, 'name': 'Collar 482520020939', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482520020939', 'sw_version': '3.0.6-D0', }), @@ -4725,14 +4655,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4752,7 +4676,6 @@ 'model_id': None, 'name': 'C6 Combiner 482523040549', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482523040549', 'sw_version': '0.1.20-D1', }), @@ -4852,14 +4775,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4879,7 +4796,6 @@ 'model_id': None, 'name': 'Enpower 654321', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '654321', 'sw_version': '1.2.2064_release/20.34', }), @@ -5273,14 +5189,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -5300,7 +5210,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.1.2', }), @@ -18167,14 +18076,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18194,7 +18097,6 @@ 'model_id': None, 'name': 'Encharge <>56', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>56', 'sw_version': '2.6.5973_rel/22.11', }), @@ -18543,14 +18445,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18570,7 +18466,6 @@ 'model_id': None, 'name': 'NC1 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -18956,14 +18851,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18983,7 +18872,6 @@ 'model_id': None, 'name': 'NC2 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -19369,14 +19257,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -19396,7 +19278,6 @@ 'model_id': None, 'name': 'NC3 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 21c1561484aa7e..d89232e9365fed 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -242,13 +242,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_hygrostat_config_entry.entry_id ) @@ -266,28 +259,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_hygrostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_hygrostat.async_unload_entry", wraps=generic_hygrostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_hygrostat_entity_entry = entity_registry.async_get( "humidifier.my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_hygrostat config entry is not in the device + # Check that the source device is not removed and the generic_hygrostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed @@ -541,7 +532,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_hygrostat_config_entry = MockConfigEntry( data={}, @@ -560,21 +551,12 @@ async def test_migration_1_1( ) generic_hygrostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 51e996c22c7ca9..5ed1c5a1d52426 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -247,13 +247,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_thermostat_config_entry.entry_id ) @@ -271,28 +264,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_thermostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_thermostat.async_unload_entry", wraps=generic_thermostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_thermostat_entity_entry = entity_registry.async_get( "climate.my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_thermostat config entry is not in the device + # Check that the source device is not removed and the generic_thermostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed @@ -554,7 +545,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_thermostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_thermostat_config_entry = MockConfigEntry( data={}, @@ -573,21 +564,12 @@ async def test_migration_1_1( ) generic_thermostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_thermostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 97861c9782ad86..1aab55c30e4238 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -755,7 +755,7 @@ async def test_migration_from_v1_with_same_keys( ( {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, [], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: # Wrong device registry, TTS subentry created @@ -770,7 +770,7 @@ async def test_migration_from_v1_with_same_keys( unique_id=None, ) ], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 # or later: Correct device registry, TTS subentry created diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 58685f5cf8f491..e0dad7c41b701c 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -259,6 +259,7 @@ dict({ 'device': dict({ 'area_id': None, + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index f2618a385a4e4d..f0736fad5ae985 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -173,18 +173,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, history_stats_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test config entry is removed when the source entity is removed.""" assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() @@ -196,15 +188,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.history_stats.async_unload_entry", wraps=history_stats.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -212,8 +201,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_history_stats") - # Check that the history_stats config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is removed @@ -383,7 +373,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes history_stats config entry from device.""" + """Test migration from v1.1 keeps the history_stats entity linked to the source device.""" history_stats_config_entry = MockConfigEntry( data={}, @@ -402,21 +392,12 @@ async def test_migration_1_1( ) history_stats_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() assert history_stats_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ac24876413d76e..1d18a527e0c95f 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -196,7 +196,10 @@ async def test_remove_stale_device( assert len(device_entries) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) - assert any( + # Identifiers are unique per config entry, so Honeywell and OtherDomain have + # separate devices for 7654321; Honeywell's devices do not carry the OtherDomain + # identifier + assert not any( ("OtherDomain", 7654321) in device.identifiers for device in device_entries ) assert len(device_entries_other) == 1 diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 2bc95fad38d9a1..5b6ea05f74640d 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -266,18 +266,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, integration_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() @@ -289,15 +281,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.integration.async_unload_entry", wraps=integration.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -306,6 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d integration_entity_entry = entity_registry.async_get("sensor.my_integration") assert integration_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -471,7 +463,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes integration config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" integration_config_entry = MockConfigEntry( data={}, @@ -491,22 +483,13 @@ async def test_migration_1_1( ) integration_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=integration_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() assert integration_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries integration_entity_entry = entity_registry.async_get("sensor.my_integration") diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index e9bfe20566456c..003a1240299cee 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -12,6 +12,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -86,3 +87,25 @@ async def test_fan_mode_service_calls( coordinator.api.async_set_fan_mode.assert_awaited_once_with( "climate_air_conditioner", expected_value ) + + +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.usefixtures("devices") +async def test_service_call_connection_error_raises_home_assistant_error( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during a service call raises HomeAssistantError.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + mock_thinq_api.async_post_device_control.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_air_conditioner", "fan_mode": "low"}, + blocking=True, + ) diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 7664a1b9bdc128..c5cb4abb6660c6 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -274,16 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d expected_helper_device_id: str | None, expected_events: list[str], ) -> None: - """Test config entry removed when the source entity is removed.""" + """Test the source entity is removed but the source device is not removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() @@ -297,15 +290,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.mold_indicator.async_unload_entry", wraps=mold_indicator.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -314,6 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert mold_indicator_entity_entry.device_id == expected_helper_device_id + # Check that the source device is not removed + assert device_registry.async_get(source_device.id) is not None + # Check if the mold_indicator config entry is not in the device source_device = device_registry.async_get(source_device.id) assert mold_indicator_config_entry.entry_id not in source_device.config_entries @@ -533,7 +526,7 @@ async def test_migration_1_1( indoor_temperature_entity_entry: er.RegistryEntry, outdoor_temperature_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes mold_indicator config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" mold_indicator_config_entry = MockConfigEntry( data={}, @@ -551,25 +544,15 @@ async def test_migration_1_1( ) mold_indicator_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - indoor_humidity_device.id, - add_config_entry_id=mold_indicator_config_entry.entry_id, - ) - - # Check preconditions - switch_device = device_registry.async_get(indoor_humidity_device.id) - assert mold_indicator_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() assert mold_indicator_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device - switch_device = device_registry.async_get(switch_device.id) - assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device + source_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1d64f4742af114..26abd4018071cb 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -70,6 +70,21 @@ WebSocketGenerator, ) + +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + TEST_SINGLE_CONFIGS = [ ( "homeassistant/device_automation/0AFFD2/bla1/config", @@ -2047,15 +2062,24 @@ async def test_cleanup_device_multiple_config_entries( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None @@ -2065,7 +2089,7 @@ async def test_cleanup_device_multiple_config_entries( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry.id, mqtt_config_entry.entry_id + mqtt_device_entry.id, mqtt_config_entry.entry_id ) assert response["success"] @@ -2165,15 +2189,24 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 1bf8a425da56dd..f5f4e52ce48858 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -46,6 +46,20 @@ ) +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.mark.no_fail_on_log_exception async def test_discover_bad_tag( hass: HomeAssistant, @@ -570,24 +584,45 @@ async def test_cleanup_tag( async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", data2) await hass.async_block_till_done() - # Verify device registry entries are created - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + # Verify device registry entries are created. Identifiers are unique per config + # entry, so the test config entry and MQTT get separate "helloworld" devices + device_entry1 = _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, ) assert device_entry1 is not None - assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} + assert device_entry1.config_entries == {config_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None - # Remove other config entry from the device + # Removing the test config entry deletes its device; the MQTT device is untouched + # and MQTT does not clear its discovery topic device_registry.async_update_device( device_entry1.id, remove_config_entry_id=config_entry.entry_id ) - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + is None ) - assert device_entry1 is not None - assert device_entry1.config_entries == {mqtt_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -595,7 +630,7 @@ async def test_cleanup_tag( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry1.id, mqtt_config_entry.entry_id + mqtt_device_entry1.id, mqtt_config_entry.entry_id ) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d16d4fd4c0b49e..340d1dcb7249c3 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -732,7 +732,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index f8d85e353e7412..73dd2a79c5f4d3 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1278,7 +1278,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 2324b01ab02acc..cda4479f3bf681 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -200,31 +200,6 @@ async def test_service_set_kvs_value( mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") -async def test_service_get_kvs_value_config_entry_not_found( - hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry -) -> None: - """Test device with no config entries.""" - entry = await init_integration(hass, 2) - - device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - - # Remove all config entries from device - device_registry.devices[device.id].config_entries.clear() - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, - blocking=True, - return_response=True, - ) - - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device.id} - - async def test_service_get_kvs_value_device_not_initialized( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index ef893776b22df0..79c37a923a350a 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -28,7 +28,7 @@ 'model_id': None, 'name': None, 'name_by_user': None, - 'primary_config_entry': None, + 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 7dca15875689d3..3901f464d219e9 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -158,18 +158,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, statistics_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the statistics config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() @@ -181,15 +173,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.statistics.async_unload_entry", wraps=statistics.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -197,6 +186,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_statistics") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +354,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes statistics config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" statistics_config_entry = MockConfigEntry( data={}, @@ -382,22 +374,13 @@ async def test_migration_1_1( ) statistics_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() assert statistics_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index d3aa4bf87dc052..c81c15a9506501 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -19,7 +19,8 @@ "realname": "John Dough", "personastateflags": 0, "gameextrainfo": "The Witcher: Enhanced Edition", - "gameid": "20900" + "gameid": "20900", + "lobbysteamid": "109775243377594361" }, { "steamid": "12345678912345678", diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 6aed898fadf9c1..f3cddd346f0330 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -208,12 +208,6 @@ async def test_device_registry_config_entry_1( device_id=device_entry.id, original_name="ABC", ) - # Add another config entry to the same device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=other_config_entry.entry_id - ) switch_as_x_config_entry = MockConfigEntry( data={}, @@ -246,15 +240,12 @@ def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) - # Remove the wrapped switch's config entry from the device, this removes the - # wrapped switch + # Remove the wrapped switch, this removes the switch_as_x config entry with patch( "homeassistant.components.switch_as_x.async_unload_entry", wraps=switch_as_x.async_unload_entry, ) as mock_setup_entry: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) + entity_registry.async_remove(switch_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -1134,9 +1125,6 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=config_entry.entry_id - ) switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", @@ -1179,19 +1167,9 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None - # Entity removed from device to prevent deletion, then added back to device - assert events == [ - { - "action": "update", - "changes": {"device_id": device_entry.id}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - { - "action": "update", - "changes": {"device_id": None}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - ] + # The switch_as_x config entry was never added to the device, so migration does + # not change the switch_as_x entity's device link + assert events == [] @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 1c987f7466c339..77a231826a268f 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -23,6 +23,20 @@ from tests.typing import MqttMockHAClient, WebSocketGenerator +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -324,12 +338,21 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + tasmota_device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) - assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert tasmota_device_entry is not None + assert tasmota_device_entry.config_entries == {tasmota_entry.entry_id} + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + assert mock_device_entry is not None async_fire_mqtt_message( hass, @@ -338,9 +361,19 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is not removed - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify the Tasmota device is removed, but the other config entry's device is not + assert ( + _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + is None + ) + device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None assert device_entry.config_entries == {mock_entry.entry_id} @@ -378,21 +411,29 @@ async def test_device_remove_multiple_config_entries_2( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == {tasmota_entry.entry_id} assert other_device_entry.id != device_entry.id - # Remove other config entry from the device + # Remove the config entry from the other (non-Tasmota) device sharing the connection + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=mock_entry.entry_id + mock_device_entry.id, remove_config_entry_id=mock_entry.entry_id ) await hass.async_block_till_done() - # Verify device entry is not removed + # Verify the Tasmota device entry is not removed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index 7c3bfb6cacf016..749bc10ebc19f5 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -74,6 +74,7 @@ async def test_migrate_entry_from_1_1( } +@pytest.mark.parametrize("collapsed_chat_index", [0, 1]) @pytest.mark.parametrize( "chats_without_notify_entity", [ @@ -86,9 +87,10 @@ async def test_migrate_entry_to_per_chat_devices( mock_external_calls: None, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + collapsed_chat_index: int, chats_without_notify_entity: tuple[int, ...], ) -> None: - """Test migrating a shared bot device to per-chat devices.""" + """Test migrating chats sharing one bot device to per-chat devices.""" bot_id = 123456 # test_user id from mock_external_calls chat_ids = (123456, 654321) config_entry = MockConfigEntry( @@ -119,22 +121,13 @@ async def test_migrate_entry_to_per_chat_devices( config_entry.add_to_hass(hass) subentry_ids = list(config_entry.subentries) - # Pre-migration state: one shared bot device associated with the config entry (None) - # and every chat subentry, holding the event entity and every chat's notify entity. + # Post-store-migration state: one shared bot device collapsed onto an arbitrary chat + # subentry, holding the event entity and every surviving chat's notify entity. bot_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_ids[collapsed_chat_index], identifiers={(DOMAIN, str(bot_id))}, ) - for subentry_id in subentry_ids: - bot_device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - config_subentry_id=subentry_id, - identifiers={(DOMAIN, str(bot_id))}, - ) - assert bot_device.config_entries_subentries == { - config_entry.entry_id: {None, *subentry_ids} - } - event_entity = entity_registry.async_get_or_create( "event", DOMAIN, @@ -161,33 +154,26 @@ async def test_migrate_entry_to_per_chat_devices( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.minor_version == 3 - # Each chat has its own device, owned by that chat's subentry and linked to the bot - # device. - chat_devices = { - chat_id: device_registry.async_get_device( + # Every chat has its own device - owned by that subentry and linked to the bot device - + # even a chat whose notify entity was deleted before the migration ran. A surviving + # notify entity is moved onto its chat's device. + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = device_registry.async_get_device( identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} ) - for chat_id in chat_ids - } - for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): - chat_device = chat_devices[chat_id] assert chat_device is not None - assert chat_device.config_entries_subentries == { - config_entry.entry_id: {subentry_id} - } + assert chat_device.config_subentry_id == subentry_id assert chat_device.via_device_id == bot_device.id + if chat_id in notify_entities: + assert ( + entity_registry.async_get(notify_entities[chat_id].entity_id).device_id + == chat_device.id + ) - # Every notify entity that survived is moved onto its chat's device - for chat_id, notify_entity in notify_entities.items(): - assert ( - entity_registry.async_get(notify_entity.entity_id).device_id - == chat_devices[chat_id].id - ) - - # The bot device ends up associated with only (entry, None), keeping the event entity + # The bot device was handed back to the config entry, keeping the event entity bot_device = device_registry.async_get(bot_device.id) assert bot_device is not None - assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}} + assert bot_device.config_subentry_id is None assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id @@ -203,34 +189,23 @@ async def test_per_chat_devices( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() - entry_id = mock_broadcast_config_entry.entry_id - # The bot device belongs to the config entry (no subentry) and holds the event entity bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) assert bot_device is not None - assert bot_device.config_entries_subentries == {entry_id: {None}} - assert bot_device.name == "Mock Title" + assert bot_device.config_subentry_id is None - for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")): - subentry_id = next( - sid - for sid, subentry in mock_broadcast_config_entry.subentries.items() - if subentry.data[CONF_CHAT_ID] == chat_id - ) + for chat_id in (123456, 654321): chat_device = device_registry.async_get_device( identifiers={(DOMAIN, f"123456_{chat_id}")} ) assert chat_device is not None - assert chat_device.config_entries_subentries == {entry_id: {subentry_id}} + assert chat_device.config_subentry_id is not None assert chat_device.via_device_id == bot_device.id - # The device is named after the chat, and its notify entity takes the device name - assert chat_device.name == chat_name notify_entity_id = entity_registry.async_get_entity_id( "notify", DOMAIN, f"123456_{chat_id}" ) assert notify_entity_id is not None assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id - assert hass.states.get(notify_entity_id).name == chat_name async def test_remove_chat_subentry_removes_per_chat_device( diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 053c81280ba797..edd85ec0dad092 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -532,7 +532,7 @@ async def test_migration_1_1( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from v1.1 removes template config entry from device.""" + """Test migration from v1.1 does not add the template config entry to the device.""" device_config_entry = MockConfigEntry() device_config_entry.add_to_hass(hass) @@ -557,21 +557,12 @@ async def test_migration_1_1( ) template_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - device_entry.id, add_config_entry_id=template_config_entry.entry_id - ) - - # Check preconditions - device_entry = device_registry.async_get(device_entry.id) - assert template_config_entry.entry_id in device_entry.config_entries - await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() assert template_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entity is linked to the source device device_entry = device_registry.async_get(device_entry.id) assert template_config_entry.entry_id not in device_entry.config_entries diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 0f92a0c0e68ed5..92bbb62fcd95f0 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -265,18 +265,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, threshold_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the threshold config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() @@ -288,15 +280,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.threshold.async_unload_entry", wraps=threshold.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -305,6 +294,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") assert threshold_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -470,7 +462,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes threshold config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" threshold_config_entry = MockConfigEntry( data={}, @@ -488,22 +480,13 @@ async def test_migration_1_1( ) threshold_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() assert threshold_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index b3af229f694889..e63493f1bb8bb4 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -93,8 +93,12 @@ def target_todo_lists( label_list_one = label_registry.async_create("label_list_one") label_list_two = label_registry.async_create("label_list_two") - device_list_one = dr.DeviceEntry(id="device_list_one") - device_list_two = dr.DeviceEntry(id="device_list_two") + device_list_one = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_one" + ) + device_list_two = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_two" + ) mock_device_registry( hass, { diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 689074c463faac..c6f9a783ef975f 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -190,18 +190,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, trend_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the trend config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() @@ -213,15 +205,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.trend.async_unload_entry", wraps=trend.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -229,6 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("binary_sensor.my_trend") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -394,7 +386,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes trend config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" trend_config_entry = MockConfigEntry( data={}, @@ -410,22 +402,13 @@ async def test_migration_1_1( ) trend_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=trend_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() assert trend_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 0cfc54fa3a2c0c..800f64359d7038 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -651,19 +651,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, utility_meter_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], ) -> None: - """Test config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed while the source device survives.""" assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() @@ -682,15 +674,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.utility_meter.async_unload_entry", wraps=utility_meter.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -703,8 +692,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d ): assert utility_meter_entity.device_id is None - # Check that the utility_meter config entry is not in the device + # Check that the source device survives and does not contain the utility_meter + # config entry sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed @@ -962,7 +953,7 @@ async def test_migration_2_1( tariffs: list[str], expected_entities: set[str], ) -> None: - """Test migration from v2.1 removes utility_meter config entry from device.""" + """Test migration from v2.1 does not add the utility_meter config entry to the device.""" utility_meter_config_entry = MockConfigEntry( data={}, @@ -983,25 +974,15 @@ async def test_migration_2_1( ) utility_meter_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() assert utility_meter_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entities are linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Check that the entities are linked to the other device entities = set() for ( utility_meter_entity diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index a5c85b57e7a49e..7cb642aa9b756f 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -201,6 +201,10 @@ async def test_migration_from_v1( "sensor_entity_id": ( "sensor.not_de_jongweg_utrecht_air_quality_index" ), + # Device 2 was created enabled; the migration moves it onto the + # disabled merged config entry, so the move re-evaluates it as disabled + # by CONFIG_ENTRY (the entity keeps its own disabled_by - propagating a + # move-disable to entities is a separate mechanism) "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, "entity_disabled_by": None, "device": 1, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c87f83a0f273e8..2fecb7157b0fc6 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -127,15 +127,30 @@ async def target_entities( area_registry.async_update(label_area.id, labels={label1.label_id}) - device1 = dr.DeviceEntry(id="device1", identifiers={("test", "device1")}) - device2 = dr.DeviceEntry(id="device2", identifiers={("test", "device2")}) + device1 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device1", + identifiers={("test", "device1")}, + ) + device2 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device2", + identifiers={("test", "device2")}, + ) area_device = dr.DeviceEntry( - id="area_device", identifiers={("test", "device3")}, area_id=kitchen_area.id + config_entry_id=config_entry.entry_id, + id="area_device", + identifiers={("test", "device3")}, + area_id=kitchen_area.id, ) label2_device = dr.DeviceEntry( - id="label_device", identifiers={("test", "device4")}, labels={label2.label_id} + config_entry_id=config_entry.entry_id, + id="label_device", + identifiers={("test", "device4")}, + labels={label2.label_id}, ) diag_only_device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, id="diag_only_device", identifiers={("test", "device5")}, area_id=garage_area.id, diff --git a/tests/components/whirlpool/snapshots/test_number.ambr b/tests/components/whirlpool/snapshots/test_number.ambr new file mode 100644 index 00000000000000..841aa241e2b12b --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_lower', + 'unique_id': 'said_oven_dual-target_temperature_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Lower oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_upper', + 'unique_id': 'said_oven_dual-target_temperature_upper', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Upper oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature', + 'unique_id': 'said_oven_single-target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Single cavity oven Target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index d86b359ed3c84a..ad2625d64019bf 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -269,64 +269,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_lower', - 'unique_id': 'said_oven_dual-oven_target_temperature_lower', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Lower oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_upper_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -447,64 +389,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_upper', - 'unique_id': 'said_oven_dual-oven_target_temperature_upper', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Upper oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.single_cavity_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -625,64 +509,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature', - 'unique_id': 'said_oven_single-oven_target_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Single cavity oven Target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.washer_detergent_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/whirlpool/test_number.py b/tests/components/whirlpool/test_number.py new file mode 100644 index 00000000000000..b2db959dfc3428 --- /dev/null +++ b/tests/components/whirlpool/test_number.py @@ -0,0 +1,177 @@ +"""Test the Whirlpool number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ( + "number.single_cavity_oven_target_temperature", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_upper_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_lower_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_number_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven target-temperature number entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +async def test_target_temperature_value( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading and updating the target temperature.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "200" + + mock.get_target_temp.return_value = 220 + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "220" + + +async def test_set_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test setting the target temperature issues a cook command.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_fractional_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a fractional target temperature is passed through without truncation.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220.5}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220.5, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_target_temperature_failure( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a failed request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +async def test_set_target_temperature_value_error( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the temperature raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +@pytest.mark.parametrize("current_mode", [whirlpool.oven.CookMode.Standby, None]) +async def test_set_target_temperature_from_idle( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + current_mode: whirlpool.oven.CookMode | None, +) -> None: + """Test that setting the temperature with no active cook defaults to Bake.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = current_mode + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Bake, cavity=cavity + ) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index cda4509462b80a..38cbb4eea01daf 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -473,3 +473,115 @@ async def test_oven_cook_mode_sensor_kept_when_used_by_automation( issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) assert issue is not None assert issue.translation_key == "deprecated_oven_cook_mode_scripts" + + +# The oven target temperature sensor has been replaced by a number entity. +DEPRECATED_TARGET_TEMP_UNIQUE_ID = "said_oven_single-oven_target_temperature" +DEPRECATED_TARGET_TEMP_ISSUE_ID = "deprecated_oven_target_temperature_said_oven_single" + + +async def test_oven_target_temperature_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated target temperature sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_target_temperature") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing target temperature sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_target_temperature") + assert state is not None + assert state.state == "200" + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) in issue_registry.issues + + +async def test_oven_target_temperature_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated target temperature sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled target temperature sensor used by an automation is kept.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_target_temperature", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_target_temperature_scripts" diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index c07f001c8e3a74..0a44756f8f4a97 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -449,3 +449,58 @@ async def test_device_two_config_entries( await hass.async_block_till_done() assert "Platform withings does not generate unique IDs" not in caplog.text + + +async def test_old_device_removal_only_removes_own_device( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + second_polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Removing an old device only removes the processing entry's own device. + + Two config entries can each own a device registry entry for the same shared sub-device. + When the sub-device disappears from one entry, it must remove its own device, not + another entry's device sharing the identifier. + """ + identifiers = {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + + def _device_for_entry(entry: MockConfigEntry) -> dr.DeviceEntry | None: + return next( + ( + device + for device in device_registry.devices.get_entries( + identifiers=identifiers + ) + if device.config_entry_id == entry.entry_id + ), + None, + ) + + # The first entry creates the sub-device and owns its device registry entry. + await setup_integration(hass, polling_config_entry, False) + assert _device_for_entry(polling_config_entry) is not None + + # Unload it, then set up a second entry: with the first entry unloaded it no longer + # provides the sub-device, so the second entry creates and owns its own device. + await hass.config_entries.async_unload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + second_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(second_polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert _device_for_entry(polling_config_entry) is not None + assert _device_for_entry(second_polling_config_entry) is not None + + # The sub-device disappears from the (still loaded) second entry's data. + withings.get_devices.return_value = [] + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only the second entry's own device was removed; the first entry's remains. + assert _device_for_entry(second_polling_config_entry) is None + assert _device_for_entry(polling_config_entry) is not None diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 445411eb6fc717..7576967bdf1a3f 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -233,8 +233,9 @@ async def test_migration_merges_duplicate_v1_entries( wolf_mock.return_value.fetch_system_list.side_effect = RequestError( "Unable to connect" ) + # Setting up the first entry loads the integration, which sets up and migrates + # every wolflink entry: the first becomes the hub and the second merges into it. await hass.config_entries.async_setup(first_entry.entry_id) - await second_entry.async_migrate(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 33da980715fd86..cbe16456a8aca8 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,9 +1,11 @@ """Tests for the Device Registry.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, nullcontext from datetime import datetime from functools import partial +import json +import pathlib import time from typing import Any from unittest.mock import ANY, patch @@ -28,6 +30,20 @@ from tests.common import MockConfigEntry, async_capture_events, flush_store +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a mock config entry and add it to hass.""" @@ -160,10 +176,27 @@ async def test_requirement_for_identifier_or_connection( ) +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: + """Test async_get raises when the registry has not been set up.""" + with pytest.raises(RuntimeError, match="Device registry not set up"): + dr.async_get(hass) + + dr.async_setup(hass) + assert isinstance(dr.async_get(hass), dr.DeviceRegistry) + + +async def test_async_load_twice_raises(hass: HomeAssistant) -> None: + """Test loading the device registry twice raises.""" + registry = dr.async_get(hass) + with pytest.raises(RuntimeError, match="Device registry is already loaded"): + await registry.async_load() + + async def test_multiple_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" + """Test registering a device for multiple config entries with same identifiers.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -191,133 +224,70 @@ async def test_multiple_config_entries( model="model", ) - assert len(device_registry.devices) == 1 - assert entry.id == entry2.id + # Identifiers and connections are unique per config entry: the two config entries + # get separate devices, while re-registering for the first entry reuses its device + assert len(device_registry.devices) == 2 + assert entry.id != entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.primary_config_entry == config_entry_1.entry_id - assert entry3.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry3.primary_config_entry == config_entry_1.entry_id + assert entry.config_entry_id == config_entry_1.entry_id + assert entry2.config_entry_id == config_entry_2.entry_id async def test_multiple_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - config_entry_1 = MockConfigEntry( + """Test re-registering a device under different subentries of one config entry.""" + config_entry = MockConfigEntry( subentries_data=( config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - ) - ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-2-1", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), ) ) - config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - entry_id = entry.id - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - } - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - -@pytest.mark.parametrize("load_registries", [False]) -async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: - """Test async_get raises when the registry has not been set up.""" - with pytest.raises(RuntimeError, match="Device registry not set up"): - dr.async_get(hass) - - dr.async_setup(hass) - assert isinstance(dr.async_get(hass), dr.DeviceRegistry) - -async def test_async_load_twice_raises(hass: HomeAssistant) -> None: - """Test loading the device registry twice raises.""" - registry = dr.async_get(hass) - with pytest.raises(RuntimeError, match="Device registry is already loaded"): - await registry.async_load() + # A device belongs to a single subentry; re-registering the same identifiers under + # another subentry of the same config entry moves the device rather than duplicating + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id == entry3.id + assert entry2.config_subentry_id == "mock-subentry-id-2" + assert entry3.config_subentry_id == "mock-subentry-id-1" @pytest.mark.parametrize("load_registries", [False]) @@ -339,6 +309,12 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_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": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -365,6 +341,9 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "has_composite_identifiers": False, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, @@ -375,6 +354,7 @@ async def test_loading_from_storage( "modified_at": modified_at, "name_by_user": "Test Friendly Name", "orphaned_timestamp": None, + "domain": None, } ], }, @@ -388,8 +368,8 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -410,8 +390,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -427,7 +407,6 @@ async def test_loading_from_storage( modified_at=datetime.fromisoformat(modified_at), name_by_user="Test Friendly Name", name="name", - primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", sw_version="version", ) @@ -445,8 +424,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -457,7 +436,6 @@ async def test_loading_from_storage( model="model", modified_at=utcnow(), name_by_user="Test Friendly Name", - primary_config_entry=mock_config_entry.entry_id, ) assert entry.id == "bcdefghijklmn" assert isinstance(entry.config_entries, set) @@ -552,8 +530,12 @@ async def test_migration_from_1_1( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -576,8 +558,12 @@ async def test_migration_from_1_1( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -593,7 +579,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -602,8 +588,8 @@ async def test_migration_from_1_1( "deleted_devices": [ { "area_id": None, - "config_entries": ["123456"], - "config_entries_subentries": {"123456": [None]}, + "config_entry_id": "123456", + "config_subentry_id": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -614,6 +600,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "orphaned_timestamp": None, + "domain": None, } ], }, @@ -705,8 +692,12 @@ async def test_migration_from_1_2( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -729,8 +720,12 @@ async def test_migration_from_1_2( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -746,7 +741,7 @@ async def test_migration_from_1_2( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -842,8 +837,12 @@ async def test_migration_fom_1_3( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -866,8 +865,12 @@ async def test_migration_fom_1_3( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -883,7 +886,7 @@ async def test_migration_fom_1_3( "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -923,7 +926,7 @@ async def test_migration_from_1_4( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -981,8 +984,12 @@ async def test_migration_from_1_4( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1005,8 +1012,12 @@ async def test_migration_from_1_4( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -1022,7 +1033,7 @@ async def test_migration_from_1_4( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1063,7 +1074,7 @@ async def test_migration_from_1_5( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -1122,8 +1133,12 @@ async def test_migration_from_1_5( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1146,8 +1161,12 @@ async def test_migration_from_1_5( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -1163,7 +1182,7 @@ async def test_migration_from_1_5( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1222,7 +1241,7 @@ async def test_migration_from_1_6( "manufacturer": None, "model": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1265,8 +1284,12 @@ async def test_migration_from_1_6( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1289,8 +1312,12 @@ async def test_migration_from_1_6( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -1306,7 +1333,7 @@ async def test_migration_from_1_6( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1367,7 +1394,7 @@ async def test_migration_from_1_7( "model": None, "model_id": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1410,8 +1437,12 @@ async def test_migration_from_1_7( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1434,8 +1465,12 @@ async def test_migration_from_1_7( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "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": "1970-01-01T00:00:00+00:00", @@ -1451,7 +1486,7 @@ async def test_migration_from_1_7( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1556,8 +1591,12 @@ async def test_migration_from_1_10( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1582,8 +1621,9 @@ async def test_migration_from_1_10( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1693,8 +1733,12 @@ async def test_migration_from_1_11( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_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": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1719,8 +1763,9 @@ async def test_migration_from_1_11( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1737,3286 +1782,2960 @@ async def test_migration_from_1_11( } -async def test_removing_config_entries( - hass: HomeAssistant, device_registry: dr.DeviceRegistry +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_12( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) + """Test migration from version 1.12. + + Version 3.1 restricts a device to a single config entry and subentry: a device + belonging to several config entries is split into one device per config entry (each + keeping a copy of the identifiers/connections and a legacy reference to the composite + id), while a device in several subentries of one config entry is collapsed onto a + single subentry (preferring a real subentry over the main entry). A device already + tied to a single config entry and subentry keeps its id. + """ config_entry_2 = MockConfigEntry() config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", + config_entry_3 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] ) - - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, + config_entry_3.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite device belonging to two config entries -> split in two + { + "area_id": "area_1", + "config_entries": [ + mock_config_entry.entry_id, + config_entry_2.entry_id, + ], + "config_entries_subentries": { + mock_config_entry.entry_id: [None], + config_entry_2.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite0000000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + }, + # Composite device spanning several subentries of one config entry -> + # split into one device per subentry (including the no-subentry one) + { + "area_id": None, + "config_entries": [config_entry_3.entry_id], + "config_entries_subentries": { + config_entry_3.entry_id: [ + None, + "mock-subentry-id-1", + "mock-subentry-id-2", + ] + }, + "configuration_url": None, + "connections": [["mac", "34:56:78:cd:ef:12"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "subentries00000000000000000000", + "identifiers": [["domain_c", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": config_entry_3.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Single (config entry, subentry) device -> keeps its id, no legacy ref + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "singleentry00000000000000000000", + "identifiers": [["domain_a", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, } - device_registry.async_clear_config_entry(config_entry_1.entry_id) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - entry3_removed = device_registry.async_get_device( - identifiers={("bridgeid", "4567")} - ) + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} - assert entry3_removed is None + # The single (config entry, subentry) device keeps its id and has no legacy reference + single = registry.async_get("singleentry00000000000000000000") + assert single is not None + assert single.config_entry_id == mock_config_entry.entry_id + assert single.config_subentry_id is None + assert single.composite_device_id is None + assert single.has_composite_identifiers is False + + # The composite spanning two config entries is split into one device per config entry + assert "composite0000000000000000000000" not in registry.devices + entry_splits = registry.async_get_devices_for_composite_device_id( + "composite0000000000000000000000" + ) + assert len(entry_splits) == 2 + assert {(d.config_entry_id, d.config_subentry_id) for d in entry_splits} == { + (mock_config_entry.entry_id, None), + (config_entry_2.entry_id, None), + } + for device in entry_splits: + assert device.id != "composite0000000000000000000000" + # Each split copies the identity and customizations of the composite ... + assert device.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert device.connections == {("mac", "12:34:56:ab:cd:ef")} + assert device.area_id == "area_1" + assert device.name_by_user == "custom name" + assert device.labels == {"lab"} + assert device.serial_number == "SERIAL" + # ... and records its composite_device_id, keeping the copied identifiers + assert device.composite_device_id == "composite0000000000000000000000" + assert device.composite_primary_config_entry == mock_config_entry.entry_id + assert device.split_at is not None + assert device.has_composite_identifiers is True + + # A device spanning several subentries of ONE config entry is an invalid state (only + # a buggy 2025.7 subentry migration produced it); it is collapsed to a single device + # on one subentry - preferring a real subentry over the main entry (None) - rather + # than split into duplicate devices sharing the same identifiers/connections. It + # keeps its id and gains no composite bookkeeping. + assert "subentries00000000000000000000" in registry.devices + assert ( + registry.async_get_devices_for_composite_device_id( + "subentries00000000000000000000" + ) + == [] + ) + collapsed = _get_device_for_config_entry( + registry, config_entry_3.entry_id, identifiers={("domain_c", "1")} + ) + assert collapsed is not None + assert collapsed.id == "subentries00000000000000000000" + assert collapsed.config_entry_id == config_entry_3.entry_id + assert collapsed.config_subentry_id == "mock-subentry-id-1" + assert collapsed.identifiers == {("domain_c", "1")} + assert collapsed.connections == {("mac", "34:56:78:cd:ef:12")} + assert collapsed.composite_device_id is None + assert collapsed.has_composite_identifiers is False - await hass.async_block_till_done() - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_backs_up_store_file( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_tmp_config_dir: str, +) -> None: + """The store file is copied to a timestamped backup before the version 3 migration.""" + hass.config.config_dir = hass_tmp_config_dir + storage_dir = pathlib.Path(hass_tmp_config_dir) / ".storage" + storage_dir.mkdir(parents=True, exist_ok=True) + old_store = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": {"devices": [], "deleted_devices": []}, } + (storage_dir / dr.STORAGE_KEY).write_text(json.dumps(old_store)) + hass_storage[dr.STORAGE_KEY] = old_store + dr.async_setup(hass) + await dr.async_load(hass) -async def test_deleted_device_removing_config_entries( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) + # Exactly one timestamped copy of the pre-migration file was made + backups = list(storage_dir.glob(f"{dr.STORAGE_KEY}.*.migration_backup")) + assert len(backups) == 1 + assert json.loads(backups[0].read_text()) == old_store + # The middle segment is a YYYYMMDD_HHMMSS timestamp (strptime raises if malformed) + datetime.strptime(backups[0].name.split(".")[-2], "%Y%m%d_%H%M%S") - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_detaches_via_device_of_dropped_parent( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child of an ownerless parent dropped by the migration has its link detached. - device_registry.async_remove_device(entry.id) - device_registry.async_remove_device(entry3.id) + The migration drops an active device with no config entry; normally + async_remove_device would clear via_device_id links to it, so the migration must too. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 2 + def _device(**overrides: Any) -> dict[str, Any]: + device = { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device0000000000000000000000000", + "identifiers": [["test", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + return device | overrides - await hass.async_block_till_done() - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Ownerless parent (no config entries) -> dropped by the migration + _device( + id="orphan0000000000000000000000000", + config_entries=[], + config_entries_subentries={}, + identifiers=[["test", "orphan"]], + primary_config_entry=None, + ), + # Child linked to the orphan via via_device_id + _device( + id="child00000000000000000000000000", + identifiers=[["test", "child"]], + via_device_id="orphan0000000000000000000000000", + ), + ], + "deleted_devices": [], }, } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } - - device_registry.async_clear_config_entry(config_entry_1.entry_id) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} - device_registry.async_clear_config_entry(config_entry_2.entry_id) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 + # The ownerless parent was dropped; the child survives with its link detached + assert registry.async_get("orphan0000000000000000000000000") is None + child = registry.async_get("child00000000000000000000000000") + assert child is not None + assert child.via_device_id is None - # Re-add, expect to keep the device id - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry2.id +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_collapses_multi_subentry_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device wrongly assigned to several subentries of one config entry collapses. - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 + Only a buggy 2025.7 subentry migration produced this state. The migration must + collapse it to a single device (preferring a real subentry over the main entry, + None), NOT split it into duplicate devices sharing the same identifiers/connections. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Sub 1", + unique_id="s1", + ), + ] + ) + entry.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None, "sub-1"]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "buggydevice00000000000000000", + "identifiers": [["test", "device-1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) - # Re-add, expect to get a new device id after the purge - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + # Collapsed to a single device (no duplicate), on the real subentry, keeping its id + assert len(registry.devices) == 1 + device = registry.async_get("buggydevice00000000000000000") + assert device is not None + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id == "sub-1" + assert device.config_entries_subentries == {entry.entry_id: {"sub-1"}} + # It is not split and stays findable by identifier and connection (not shadowed) + assert ( + registry.async_get_devices_for_composite_device_id( + "buggydevice00000000000000000" + ) + == [] + ) + assert device.composite_device_id is None + assert device.has_composite_identifiers is False + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, identifiers={("test", "device-1")} + ) + is device + ) + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, connections={("mac", "12:34:56:ab:cd:ef")} + ) + is device ) - assert entry3.id != entry4.id -async def test_removing_config_subentries( +async def test_async_get_or_create_moves_device_between_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Re-registering under a different subentry moves the device, not duplicates it. + + Identifiers and connections are unique per config entry (not per subentry), so a + second async_get_or_create with the same identifier/connection but a different + subentry of the same config entry moves the existing device - it neither creates a + duplicate nor raises. + """ + entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="sub-1", subentry_type="test", - title="Mock title", - unique_id="test", + title="1", + unique_id="s1", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="sub-2", subentry_type="test", - title="Mock title", - unique_id="test", + title="2", + unique_id="s2", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + entry.add_to_hass(hass) + + # Same identifier, different subentry -> the existing device is moved + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + identifiers={("test", "1")}, + ) + assert device.config_subentry_id == "sub-1" + moved = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + identifiers={("test", "1")}, + ) + assert moved.id == device.id + assert moved.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 1 + + # Same connection, different subentry -> also moved, not duplicated + device_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + connections={("mac", "12:34:56:ab:cd:ef")}, ) - config_entry_2.add_to_hass(hass) + moved_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + assert moved_2.id == device_2.id + assert moved_2.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 2 - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + +async def test_async_get_device_returns_first_match_for_ambiguous_lookup( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Independent devices sharing an identifier resolve to the first match. + + They are not splits of one pre-migration composite (no shared composite_device_id), + so there is nothing to merge and the lookup returns one of the real devices rather + than a composite. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - - assert len(device_registry.devices) == 1 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - device_registry.async_update_device( - entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } + assert device_1.id != device_2.id - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } + match = device_registry.async_get_device(identifiers={("test", "shared")}) + # A real registry device (the first match), not a synthesized composite + assert match is device_1 + assert match.id in device_registry.devices + assert match.config_entries == {entry_1.entry_id} - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None - assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None - await hass.async_block_till_done() +async def test_async_get_device_prefers_calling_integration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An ambiguous lookup prefers a device owned by the calling integration.""" + entry_a = MockConfigEntry(domain="itg_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="itg_b") + entry_b.add_to_hass(hass) + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + # itg_a's device is indexed first (created first) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, connections={mac} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, connections={mac} + ) + assert device_a.id != device_b.id + + # Each integration resolves to its own device, regardless of index order + with patch.object(dr, "_current_integration_domain", return_value="itg_b"): + assert device_registry.async_get_device(connections={mac}) is device_b + with patch.object(dr, "_current_integration_domain", return_value="itg_a"): + assert device_registry.async_get_device(connections={mac}) is device_a + + # A caller owning neither, or no integration frame, falls back to the first match + with patch.object(dr, "_current_integration_domain", return_value="other"): + assert device_registry.async_get_device(connections={mac}) is device_a + with patch.object(dr, "_current_integration_domain", return_value=None): + assert device_registry.async_get_device(connections={mac}) is device_a + + +async def test_async_get_device_prefers_matching_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A lookup prefers the device whose config entry domain matches the identifier. - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } + Right after the migration split, and until identifiers are pruned, every split still + carries the composite's full identifier set, so a lookup matches all splits; the + domain match resolves it to the correct single device without a composite. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + # entry_b's device also carries domain_a's identifier (unpruned split state) + device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, + identifiers={("domain_a", "1"), ("domain_b", "2")}, + ) + assert device_registry.async_get_device(identifiers={("domain_a", "1")}) is device_a -async def test_deleted_device_removing_config_subentries( +async def test_async_remove_device_fans_out_to_migration_composite( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-2", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + """async_remove_device on a pre-migration composite id removes its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - config_entry_2.add_to_hass(hass) - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + device_registry.async_remove_device(old_id) + + assert device_1.id not in device_registry.devices + assert device_2.id not in device_registry.devices + + +async def test_async_update_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_update_device on a pre-migration composite id fans out to its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } + device_registry.async_update_device(old_id, name_by_user="merged") - device_registry.async_remove_device(entry.id) + assert device_registry.async_get(device_1.id).name_by_user == "merged" + assert device_registry.async_get(device_2.id).name_by_user == "merged" - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - await hass.async_block_till_done() +async def test_get_entry_by_connection_without_config_entry_scope( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The container resolves by connection when no config entry scope is given.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, connections={connection} + ) + assert device_registry.devices.get_entry(connections={connection}) is device - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - "device": entry4.dict_repr, - } - device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None +async def test_update_unknown_device_id_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Updating an id that is neither a real device nor a composite raises.""" + with pytest.raises(KeyError): + device_registry.async_update_device("unknown0000000000000000000000ab", name="x") - # Remove the same subentry again - device_registry.async_clear_config_subentry( - config_entry_1.entry_id, "mock-subentry-id-1-1" - ) - assert ( - device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + +async def test_cleanup_removes_device_referencing_missing_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Cleanup drops a device still referencing a config entry that no longer exists.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} ) + # An entity keeps the device out of the plain-orphan sweep so the defensive + # missing-config-entry path is reached + entity_registry.async_get_or_create("sensor", "test", "unique", device_id=device.id) - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - assert entry.orphaned_timestamp is None + # The device's config entry is no longer known to hass + with patch.object(hass.config_entries, "async_entry_ids", return_value=[]): + dr.async_cleanup(hass, device_registry, entity_registry) - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None + assert device.id not in device_registry.devices - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - # Re-add, expect to keep the device id - hass.config_entries.async_add_subentry( - config_entry_2, - config_entries.ConfigSubentry( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - restored_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", +async def test_clear_config_entry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning config + entry must remove the device rather than complete that move to the other entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - assert restored_entry.id == entry.id + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) - # Remove again, and trigger purge - device_registry.async_remove_device(entry.id) - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None + device_registry.async_clear_config_entry(entry_1.entry_id) - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 +async def test_clear_config_entry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry drops a pending move that targets it. - # Re-add, expect to get a new device id after the purge - new_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + A device owned by another entry can hold a transient pending move to the entry being + removed; clearing it stops a later completion from moving the device onto the removed + entry instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - assert new_entry.id != entry.id + # Start a deferred move to entry_2 (add_config_entry_id without the paired remove yet) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + # entry_2 is torn down before the move completes + device_registry.async_clear_config_entry(entry_2.entry_id) -async def test_removing_area_id( - device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry + # Completing the move by removing the owner must delete the device, not move it onto + # the removed entry_2 + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +async def test_move_to_config_entry_clears_target_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we can clear area id.""" - entry = 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={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + """Moving a device into a config entry clears a matching deleted device it holds. + + A retained-identity move adds no new identifiers/connections, so the deleted device the + target entry kept for the same identity must still be removed - otherwise the active + device and the deleted device share the target entry's per-identity slot. + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "shared")} ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert device_a.id != device_b.id - entry_w_area = device_registry.async_update_device(entry.id, area_id="12345A") + # Leave a deleted device owned by entry_b with the shared identity + device_registry.async_remove_device(device_b.id) + assert device_b.id in device_registry.deleted_devices - device_registry.async_clear_area_id("12345A") - entry_wo_area = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + # Move device_a into entry_b, retaining its identity + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_b.entry_id + ) - assert not entry_wo_area.area_id - assert entry_w_area != entry_wo_area + assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id + # The deleted device entry_b held for the same identity is cleared, not left immortal + assert device_b.id not in device_registry.deleted_devices -async def test_removing_area_id_deleted_device( - device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we can clear area id.""" - entry1 = 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={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, - identifiers={("bridgeid", "1234")}, - manufacturer="manufacturer", - model="model", - ) + """Passing both via_device and via_device_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) - entry1_w_area = device_registry.async_update_device(entry1.id, area_id="12345A") - entry2_w_area = device_registry.async_update_device(entry2.id, area_id="12345B") + with pytest.raises(HomeAssistantError, match="not allowed"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "1")}, + via_device=("test", "via"), + via_device_id="via-device-id", + ) - device_registry.async_remove_device(entry1.id) - device_registry.async_remove_device(entry2.id) + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 - device_registry.async_clear_area_id("12345A") - entry1_restored = 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={("bridgeid", "0123")}, + +async def test_get_or_create_invalid_subentry_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An unknown config_subentry_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="has no subentry"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="does-not-exist", + identifiers={("test", "1")}, + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_add_current_config_entry_is_noop( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding the device's current owner records no pending move. + + So a later removal of that sole owner deletes the device instead of moving it to + itself. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} ) - entry2_restored = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, - identifiers={("bridgeid", "1234")}, + + device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id) + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id ) - assert not entry1_restored.area_id - assert entry2_restored.area_id == "12345B" - assert entry1_w_area != entry1_restored - assert entry2_w_area != entry2_restored + assert result is None + assert device.id not in device_registry.devices -async def test_specifying_via_device_create( +@pytest.mark.parametrize( + "clear_domain", + ["light", None], + ids=["explicit-domain", "auto-resolved-domain"], +) +async def test_reregister_restores_orphan( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, + clear_domain: str | None, ) -> None: - """Test specifying a via_device and removal of the hub device.""" - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) + """Re-adding an integration restores its orphan. - via = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("hue", "0123")}, - manufacturer="manufacturer", - model="via", + async_clear_config_entry records the config entry's domain - passed in by the core + removal flow, or resolved from the still-present entry when omitted - and a later + async_get_or_create under the same domain restores that orphan (id, labels, name) + rather than create a fresh device. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")}, name="Original" + ) + device_registry.async_update_device( + device.id, name_by_user="Custom", labels={"label1"} ) - light = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "456")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "0123"), + # Removing the config entry orphans the deleted device (config_entry_id=None) + device_registry.async_clear_config_entry(entry.entry_id, clear_domain) + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "light" + + # Re-add the integration under a new config entry and re-register the device + new_entry = MockConfigEntry(domain="light") + new_entry.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=new_entry.entry_id, identifiers={("light", "1")} ) - assert light.via_device_id == via.id + assert restored.id == device.id + assert restored.config_entry_id == new_entry.entry_id + assert restored.name_by_user == "Custom" + assert restored.labels == {"label1"} - device_registry.async_remove_device(via.id) - light = device_registry.async_get_device(identifiers={("hue", "456")}) - assert light.via_device_id is None - # A device with a non existing via_device reference should create - light_via_nonexisting_parent_device = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "789")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "non_existing_123"), +async def test_orphan_not_restored_for_other_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan recorded for one integration is not restored by another. + + Identifiers and connections are no longer unique across integrations, so a chance + collision must not restore another integration's orphaned device onto this one. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")} ) - assert { - "calls `device_registry.async_get_or_create` " - "referencing a non existing `via_device` " - '("hue","non_existing_123")' in caplog.text - } - assert light_via_nonexisting_parent_device is not None - assert light_via_nonexisting_parent_device.via_device_id is None - nonexisting_parent_device = device_registry.async_get_device( - identifiers={("hue", "non_existing_123")} + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + assert device_registry.deleted_devices[device.id].domain == "light" + + # A different integration registering a device with the same identifiers gets a fresh + # device, and the orphan is left intact for its own integration to restore later + other_entry = MockConfigEntry(domain="switch") + other_entry.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("light", "1")} ) - assert nonexisting_parent_device is None + assert fresh.id != device.id + assert device.id in device_registry.deleted_devices -async def test_specifying_via_device_update( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, +async def test_orphaning_replaces_colliding_same_domain_orphan( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test specifying a via_device and updating.""" - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) + """Orphaning a device drops a stale same-domain orphan it collides with. - light = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "456")}, - manufacturer="manufacturer", - model="light", - name="Light", - via_device=("hue", "0123"), + Two devices from the same integration sharing a connection both orphan under + config_entry_id=None and would collide in the lookup index; the newest orphan replaces + the stale one so a re-add restores it deterministically. + """ + connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")} + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + connections=connections, + identifiers={("hue", "1")}, + ) + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + + device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) + assert device_1.id in device_registry.deleted_devices + + device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain) + # The newer orphan replaces the stale one it collides with on the shared connection + assert device_1.id not in device_registry.deleted_devices + assert device_2.id in device_registry.deleted_devices + + # Re-adding under the same domain restores the surviving orphan + entry_3 = MockConfigEntry(domain="hue") + entry_3.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=entry_3.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + assert restored.id == device_2.id + + +async def test_orphaned_domain_survives_store_round_trip( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan's recorded domain is written to and read back from storage.""" + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) - assert light.via_device_id is None + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + await registry2.async_load() - via = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("hue", "0123")}, - manufacturer="manufacturer", - model="via", - ) + assert registry2.deleted_devices[device.id].domain == "hue" - light = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "456")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "0123"), - ) - assert light.via_device_id == via.id - assert light.name == "Light" +async def test_orphan_keeps_domain_when_config_entry_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan keeps its domain when its config entry is removed via the normal flow. - # Try updating with a non existing via device - light = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "456")}, - manufacturer="manufacturer", - model="light", - name="New light", - via_device=("hue", "non_existing_abc"), + config_entries deletes the entry from the registry before calling + async_clear_config_entry, so async_remove_device can no longer look up the domain and + records None; the domain passed to async_clear_config_entry is what preserves it on + the orphan. Without it the orphan would have domain=None and, with the domain-less + restore fallback gone, could never be restored. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} ) - assert { - "calls `device_registry.async_get_or_create` " - "referencing a non existing `via_device` " - '("hue","non_existing_123")' in caplog.text - } - # Assert the name was updated correctly - assert light.via_device_id == via.id - assert light.name == "New light" + await hass.config_entries.async_remove(entry.entry_id) -async def test_loading_saving_data( + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "hue" + + +async def test_cross_domain_orphans_do_not_shadow( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test that we load/save data correctly.""" - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) - config_entry_4 = MockConfigEntry() - config_entry_4.add_to_hass(hass) - config_entry_5 = MockConfigEntry() - config_entry_5.add_to_hass(hass) + """Orphans from different integrations sharing an identifier stay independently found. - orig_via = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("hue", "0123")}, - manufacturer="manufacturer", - model="via", - name="Original Name", - sw_version="Orig SW 1", - entry_type=None, + Both orphans would otherwise collide in the config_entry_id=None index; keying orphans + by their recorded domain keeps each restorable by its own integration. + """ + shared = {("test", "shared")} + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers=shared + ) + entry_b = MockConfigEntry(domain="mqtt") + entry_b.add_to_hass(hass) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers=shared ) - orig_light = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "456")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "0123"), - disabled_by=dr.DeviceEntryDisabler.USER, + device_registry.async_clear_config_entry(entry_a.entry_id, entry_a.domain) + device_registry.async_clear_config_entry(entry_b.entry_id, entry_b.domain) + + # Re-adding under each domain restores that domain's own orphan, not the other's + entry_c = MockConfigEntry(domain="mqtt") + entry_c.add_to_hass(hass) + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_c.entry_id, identifiers=shared ) + assert restored_b.id == device_b.id - orig_light2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections=set(), - identifiers={("hue", "789")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "0123"), + entry_d = MockConfigEntry(domain="hue") + entry_d.add_to_hass(hass) + restored_a = device_registry.async_get_or_create( + config_entry_id=entry_d.entry_id, identifiers=shared ) + assert restored_a.id == device_a.id - device_registry.async_remove_device(orig_light2.id) - orig_light3 = device_registry.async_get_or_create( - config_entry_id=config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, - identifiers={("hue", "abc")}, - manufacturer="manufacturer", - model="light", - ) +async def test_domainless_orphan_not_restored( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A domain-less orphan is not restored; re-registering creates a fresh device. - device_registry.async_get_or_create( - config_entry_id=config_entry_4.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, - identifiers={("abc", "123")}, - manufacturer="manufacturer", - model="light", + The migration carries orphans over without a domain, which can't be resolved once the + config entry is gone. Orphans are matched only on their recorded domain, so a + domain-less one is left for the periodic purge and re-registering makes a new device. + """ + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} ) - device_registry.async_remove_device(orig_light3.id) + # Simulate an orphan whose domain can no longer be resolved (the migration carries + # orphans over without one) + with patch.object(hass.config_entries, "async_get_entry", return_value=None): + device_registry.async_clear_config_entry(entry_1.entry_id) + assert device_registry.deleted_devices[device_1.id].domain is None - orig_light4 = device_registry.async_get_or_create( - config_entry_id=config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, - identifiers={("hue", "abc")}, - manufacturer="manufacturer", - model="light", - entry_type=dr.DeviceEntryType.SERVICE, + # Re-registering the shared identifier does not restore the domain-less orphan + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} ) + assert fresh.id != device_1.id + # The un-restored orphan lingers until the periodic purge + assert device_1.id in device_registry.deleted_devices - assert orig_light4.id == orig_light3.id - orig_kitchen_light = device_registry.async_get_or_create( - config_entry_id=config_entry_5.entry_id, - connections=set(), - identifiers={("hue", "999")}, - manufacturer="manufacturer", - model="light", - via_device=("hue", "0123"), - disabled_by=dr.DeviceEntryDisabler.USER, - suggested_area="Kitchen", - ) - - assert len(device_registry.devices) == 4 - assert len(device_registry.deleted_devices) == 1 +async def test_clear_config_subentry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry removes its device, ignoring a pending move. - orig_via = device_registry.async_update_device( - orig_via.id, - area_id="mock-area-id", - name_by_user="mock-name-by-user", - labels={"mock-label1", "mock-label2"}, + add_config_entry_id records a transient pending move; tearing down the owning + subentry must remove the device rather than complete that move. + """ + entry_1 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("test", "1")}, ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) - # Now load written data in new registry - registry2 = dr.DeviceRegistry(hass) - await flush_store(device_registry._store) - await registry2.async_load() + device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") - # Ensure same order - assert list(device_registry.devices) == list(registry2.devices) - assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None - new_via = registry2.async_get_device(identifiers={("hue", "0123")}) - new_light = registry2.async_get_device(identifiers={("hue", "456")}) - new_light4 = registry2.async_get_device(identifiers={("hue", "abc")}) - assert orig_via == new_via - assert orig_light == new_light - assert orig_light4 == new_light4 +async def test_clear_config_subentry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry drops a pending move that targets it. - # Ensure enums converted - for old, new in ( - (orig_via, new_via), - (orig_light, new_light), - (orig_light4, new_light4), - ): - assert old.disabled_by is new.disabled_by - assert old.entry_type is new.entry_type + A device owned by another entry can hold a transient pending move to the subentry being + removed; clearing it stops a later completion from validating against the removed + subentry (moving the device onto it, or raising) instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move into entry_2's subentry + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) - # Ensure a save/load cycle does not keep suggested area - new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")}) - assert orig_kitchen_light.area_id == "kitchen" + # The target subentry is torn down before the move completes + device_registry.async_clear_config_subentry(entry_2.entry_id, "mock-subentry-id-1") - orig_kitchen_light_without_suggested_area = device_registry.async_update_device( - orig_kitchen_light.id, suggested_area=None + # Completing the move by removing the owner must delete the device, not move it onto + # the removed subentry + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id ) - assert orig_kitchen_light_without_suggested_area.area_id == "kitchen" - assert orig_kitchen_light_without_suggested_area == new_kitchen_light + assert result is None + assert device.id not in device_registry.devices -async def test_no_unnecessary_changes( - device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_composite_reuses_pre_migration_id( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Make sure we do not consider devices changes.""" - entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, - identifiers={("hue", "456"), ("bla", "123")}, - ) - with patch( - "homeassistant.helpers.device_registry.DeviceRegistry.async_schedule_save" - ) as mock_save: - entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, identifiers={("hue", "456")} - ) + """A composite over migration splits reuses the pre-migration device id. - assert entry.id == entry2.id - assert len(mock_save.mock_calls) == 0 + Backwards compatibility for unmodified integrations: before the rewrite a shared + connection resolved to one device with a stable id that stored references + (automations, an entity device_id, a fired event device_id) use. The composite over + that device's splits reuses the same id, so those references keep resolving; a + transient id is minted only for a runtime ambiguity between independent devices. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "aa:bb:cc:dd:ee:ff"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.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) + # A connections-only lookup matches both splits -> composite reuses the old id + composite = registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + ) + assert composite is not None + assert composite.id == "composite00000000000000000000" + assert composite.id not in registry.devices + # It is the same composite async_get resolves for the old id + assert registry.async_get("composite00000000000000000000").id == composite.id + # An identifier lookup still domain-resolves to the single owning split (real id) + resolved = registry.async_get_device(identifiers={("domain_a", "1")}) + assert resolved.id in registry.devices + assert resolved.config_entry_id == entry_a.entry_id -async def test_format_mac( - device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry + +@pytest.mark.parametrize( + "update_kwargs", + [ + pytest.param({"new_identifiers": {("test", "new")}}, id="new_identifiers"), + pytest.param( + {"new_connections": {("mac", "12:34:56:ab:cd:ef")}}, id="new_connections" + ), + pytest.param( + {"merge_identifiers": {("test", "extra")}}, id="merge_identifiers" + ), + pytest.param( + {"merge_connections": {("mac", "12:34:56:ab:cd:ef")}}, + id="merge_connections", + ), + ], +) +async def test_async_update_device_composite_drops_identity_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + update_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, ) -> None: - """Make sure we normalize mac addresses.""" - entry = 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")}, + """Identity-rewriting args are ambiguous on a composite: dropped with a warning.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - for mac in ("123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"): - test_entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac)}, - ) - assert test_entry.id == entry.id, mac - assert test_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") - } - # This should not raise - for invalid in ( - "invalid_mac", - "123456ABCDEFG", # 1 extra char - "12:34:56:ab:cdef", # not enough : - "12:34:56:ab:cd:e:f", # too many : - "1234.56abcdef", # not enough . - "123.456.abc.def", # too many . - ): - invalid_mac_entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, invalid)}, - ) - assert list(invalid_mac_entry.connections)[0][1] == invalid + # No raise; the arg is ignored with a report-issue warning, devices untouched + device_registry.async_update_device(old_id, **update_kwargs) + assert "async_entries_for_config_entry" in caplog.text + assert "report this issue" in caplog.text + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_1.id).connections == set() + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + assert device_registry.async_get(device_2.id).connections == set() -async def test_update( +async def test_async_update_device_composite_drops_only_disallowed_args( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: - """Verify that we can update some attributes of a device.""" - created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") - freezer.move_to(created_at) - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = 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={("hue", "456"), ("bla", "123")}, + """A composite update applies the allowed args and drops the disallowed ones.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - new_connections = {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} - new_identifiers = {("hue", "654"), ("bla", "321")} - assert not entry.area_id - assert not entry.labels - assert not entry.name_by_user - assert entry.created_at == created_at - assert entry.modified_at == created_at - - modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") - freezer.move_to(modified_at) - with patch.object(device_registry, "async_schedule_save") as mock_save: - updated_entry = device_registry.async_update_device( - entry.id, - area_id="12345A", - configuration_url="https://example.com/config", - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version", - labels={"label1", "label2"}, - manufacturer="Test Producer", - model="Test Model", - model_id="Test Model Name", - name_by_user="Test Friendly Name", - name="name", - new_connections=new_connections, - new_identifiers=new_identifiers, - serial_number="serial_no", - suggested_area="suggested_area", - sw_version="version", - via_device_id="98765B", - ) - assert mock_save.call_count == 1 - assert updated_entry != entry - assert updated_entry == dr.DeviceEntry( - area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, - configuration_url="https://example.com/config", - connections={("mac", "65:43:21:fe:dc:ba")}, - created_at=created_at, - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version", - id=entry.id, - identifiers={("bla", "321"), ("hue", "654")}, - labels={"label1", "label2"}, - manufacturer="Test Producer", - model="Test Model", - model_id="Test Model Name", - modified_at=modified_at, - name_by_user="Test Friendly Name", - name="name", - serial_number="serial_no", - suggested_area="suggested_area", - sw_version="version", - via_device_id="98765B", + device_registry.async_update_device( + old_id, + new_identifiers={("test", "renamed")}, # disallowed -> dropped + name_by_user="Custom name", # allowed -> applied to every underlying device ) + assert "new_identifiers" in caplog.text + # Allowed arg applied to both underlying devices + assert device_registry.async_get(device_1.id).name_by_user == "Custom name" + assert device_registry.async_get(device_2.id).name_by_user == "Custom name" + # Disallowed arg dropped: identities untouched + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} - assert device_registry.async_get_device(identifiers={("hue", "456")}) is None - assert device_registry.async_get_device(identifiers={("bla", "123")}) is None - assert ( - device_registry.async_get_device(identifiers={("hue", "654")}) == updated_entry +async def test_async_update_device_composite_drops_move_args( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """new_config_entry_id / new_config_subentry_id are dropped on the composite path. + + A forwarded move can't be caught by the identifier/connection checks - the splits have + distinct identities and would move without colliding - so assert each split keeps its + original (config entry, subentry). + """ + entry_1 = MockConfigEntry( + domain="test", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, subentry_type="test", title="Sub", unique_id=None + ) + ], ) - assert ( - device_registry.async_get_device(identifiers={("bla", "321")}) == updated_entry + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + subentry_id = next(iter(entry_1.subentries)) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - - assert ( - device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} - ) - is None + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} ) - assert ( - device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} - ) - == updated_entry + old_id = "composite00000000000000000000ab" + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - assert device_registry.async_get(updated_entry.id) is not None + # Targets are valid, so a forwarded move would land silently - only the owner + # assertions below catch it. + device_registry.async_update_device(old_id, new_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(old_id, new_config_subentry_id=subentry_id) - await hass.async_block_till_done() + assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id + assert device_registry.async_get(device_1.id).config_subentry_id is None + assert device_registry.async_get(device_2.id).config_entry_id == entry_2.entry_id - assert len(update_events) == 2 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "area_id": None, - "connections": {("mac", "12:34:56:ab:cd:ef")}, - "configuration_url": None, - "disabled_by": None, - "entry_type": None, - "hw_version": None, - "identifiers": {("bla", "123"), ("hue", "456")}, - "labels": set(), - "manufacturer": None, - "model": None, - "model_id": None, - "name": None, - "name_by_user": None, - "serial_number": None, - "suggested_area": None, - "sw_version": None, - "via_device_id": None, + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_drops_device_without_config_entries( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """A device with no config entry / subentry pairs is dropped during migration.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Orphan device with no config entries -> dropped + { + "area_id": None, + "config_entries": [], + "config_entries_subentries": {}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "orphan00000000000000000000000", + "identifiers": [["domain_a", "orphan"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Normal single-config-entry device -> kept + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "keptdevice0000000000000000000", + "identifiers": [["domain_a", "kept"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], }, } - with pytest.raises(HomeAssistantError): - device_registry.async_update_device( - entry.id, - merge_connections=new_connections, - new_connections=new_connections, - ) - with pytest.raises(HomeAssistantError): - device_registry.async_update_device( - entry.id, - merge_identifiers=new_identifiers, - new_identifiers=new_identifiers, - ) + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + # The orphan device was dropped, the normal device kept + assert registry.async_get("orphan00000000000000000000000") is None + assert "orphan00000000000000000000000" not in registry.devices + kept = registry.async_get("keptdevice0000000000000000000") + assert kept is not None + assert kept.config_entry_id == mock_config_entry.entry_id + assert len(registry.devices) == 1 -@pytest.mark.parametrize( - ("initial_connections", "new_connections", "updated_connections"), - [ - ( # No connection -> single connection - None, - {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - ), - ( # No connection -> double connection - None, - { - (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), - (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), - }, - { - (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), - (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), - }, - ), - ( # single connection -> no connection - {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, - set(), - set(), - ), - ( # single connection -> single connection - {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, - {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - ), - ( # single connection -> double connection - {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, - { - (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), - (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), - }, - { - (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), - (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), - }, - ), - ( # Double connection -> None - { - (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), - (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), - }, - set(), - set(), - ), - ( # Double connection -> single connection - { - (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), - (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), - }, - {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, - {(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba")}, - ), - ], -) -async def test_update_connection( - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - initial_connections: set[tuple[str, str]] | None, - new_connections: set[tuple[str, str]] | None, - updated_connections: set[tuple[str, str]] | None, + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_splits_deleted_device_with_multiple_config_entries( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Verify that we can update some attributes of a device.""" - entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections=initial_connections, - identifiers={("hue", "456"), ("bla", "123")}, - ) + """A deleted device belonging to several config entries is split, one per entry. - with patch.object(device_registry, "async_schedule_save") as mock_save: - updated_entry = device_registry.async_update_device( - entry.id, - new_connections=new_connections, - ) + Each split keeps the identity and customizations so every config entry can still + restore its share when a matching device is re-registered. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "deleted_devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": "deletedcomposite0000000000000", + "identifiers": [["domain_a", "1"]], + "labels": ["lab"], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "orphaned_timestamp": None, + } + ], + }, + } - assert mock_save.call_count == 1 - assert updated_entry != entry - assert updated_entry.connections == updated_connections - assert ( - device_registry.async_get_device(identifiers={("bla", "123")}) == updated_entry + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Split into one deleted device per config entry, each keeping identity/customizations + assert len(registry.deleted_devices) == 2 + assert "deletedcomposite0000000000000" not in registry.deleted_devices + by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()} + assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id} + for deleted in by_entry.values(): + assert deleted.identifiers == {("domain_a", "1")} + assert deleted.connections == {("mac", "12:34:56:ab:cd:ef")} + assert deleted.name_by_user == "custom name" + assert deleted.area_id == "area_1" + + # Each config entry can restore its share, with the customizations preserved + restored_a = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_a.config_entry_id == entry_a.entry_id + assert restored_a.name_by_user == "custom name" + + restored_b = registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("domain_a", "1")} ) + assert restored_b.config_entry_id == entry_b.entry_id + assert restored_b.name_by_user == "custom name" + assert restored_a.id != restored_b.id -async def test_update_remove_config_entries( +async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry removes the devices that belong to it.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - entry4 = device_registry.async_update_device( - entry2.id, add_config_entry_id=config_entry_3.entry_id - ) - # Try to add an unknown config entry - with pytest.raises(HomeAssistantError): - device_registry.async_update_device(entry2.id, add_config_entry_id="blabla") - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id == entry4.id + # Same identifiers on different config entries are separate devices + assert len(device_registry.devices) == 3 + assert entry.id != entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_1.entry_id - ) - updated_entry = device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_3.entry_id + device_registry.async_clear_config_entry(config_entry_1.entry_id) + + # Clearing config_entry_1 removes its two devices, leaving config_entry_2's + assert len(device_registry.devices) == 1 + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry3.id) is None + assert device_registry.async_get(entry2.id) is not None + + +async def test_deleted_device_removing_config_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test clearing a config entry orphans its deleted devices.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, ) - removed_entry = device_registry.async_update_device( - entry3.id, remove_config_entry_id=config_entry_1.entry_id + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, + identifiers={("bridgeid", "4567")}, ) - assert updated_entry.config_entries == {config_entry_2.entry_id} - assert removed_entry is None + device_registry.async_remove_device(entry.id) + device_registry.async_remove_device(entry2.id) + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 2 - removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) + device_registry.async_clear_config_entry(config_entry_1.entry_id) - assert removed_entry is None + # Deleted devices are kept but orphaned (config entry cleared) so they can be purged + assert len(device_registry.deleted_devices) == 2 + assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert ( + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry_2.entry_id + ) - await hass.async_block_till_done() - - assert len(update_events) == 7 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[6].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + device_registry.async_clear_config_entry(config_entry_2.entry_id) + assert len(device_registry.deleted_devices) == 2 + assert device_registry.deleted_devices[entry2.id].config_entry_id is None -async def test_update_remove_config_subentries( +async def test_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry removes the devices that belong to it.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( + config_entry.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, + identifiers={("bridgeid", "4567")}, + ) + + assert len(device_registry.devices) == 2 + assert entry.config_subentry_id == "mock-subentry-id-1" + assert entry2.config_subentry_id == "mock-subentry-id-2" + + device_registry.async_clear_config_subentry( + config_entry.entry_id, "mock-subentry-id-1" + ) + + # Only the device on the cleared subentry is removed + assert len(device_registry.devices) == 1 + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry2.id) is not None + + +async def test_deleted_device_removing_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test clearing a config subentry orphans its deleted devices.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-2-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - ) + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] ) - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry_id = entry.id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - } + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, + identifiers={("bridgeid", "4567")}, + ) - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", + device_registry.async_remove_device(entry.id) + device_registry.async_remove_device(entry2.id) + assert len(device_registry.deleted_devices) == 2 + + device_registry.async_clear_config_subentry( + config_entry.entry_id, "mock-subentry-id-1" ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - # Try adding the same subentry again + # Only the deleted device on the cleared subentry is orphaned + assert len(device_registry.deleted_devices) == 2 + assert device_registry.deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - is entry + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry.entry_id ) - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_2.entry_id, - add_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id=None, - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - # Try to add a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't add config subentry without specifying config entry", - ): - device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") +async def test_removing_area_id( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: + """Make sure we can clear area id.""" + entry = 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={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) - # Try to add an unknown subentry - with pytest.raises( - HomeAssistantError, - match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", - ): - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id="blabla", - ) + entry_w_area = device_registry.async_update_device(entry.id, area_id="12345A") - # Try to remove a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't remove config subentry without specifying config entry", - ): - device_registry.async_update_device( - entry_id, remove_config_subentry_id="blabla" - ) + device_registry.async_clear_area_id("12345A") + entry_wo_area = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert len(device_registry.devices) == 1 + assert not entry_wo_area.area_id + assert entry_w_area != entry_wo_area - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - # Try removing the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - is entry +async def test_removing_area_id_deleted_device( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: + """Make sure we can clear area id.""" + entry1 = 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={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + identifiers={("bridgeid", "1234")}, + manufacturer="manufacturer", + model="model", ) - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } + entry1_w_area = device_registry.async_update_device(entry1.id, area_id="12345A") + entry2_w_area = device_registry.async_update_device(entry2.id, area_id="12345B") - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_2.entry_id, - remove_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_3.entry_id: {None}, - } + device_registry.async_remove_device(entry1.id) + device_registry.async_remove_device(entry2.id) - entry_before_remove = entry - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_3.entry_id, - remove_config_subentry_id=None, + device_registry.async_clear_area_id("12345A") + entry1_restored = 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={("bridgeid", "0123")}, + ) + entry2_restored = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + identifiers={("bridgeid", "1234")}, ) - assert entry is None - - await hass.async_block_till_done() - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry_id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry_id, - "device": entry_before_remove.dict_repr, - } + assert not entry1_restored.area_id + assert entry2_restored.area_id == "12345B" + assert entry1_w_area != entry1_restored + assert entry2_w_area != entry2_restored -@pytest.mark.parametrize( - ("initial_area", "device_area_id", "number_of_areas"), - [ - (None, None, 0), - ("Living Room", "living_room", 1), - ], -) -async def test_update_suggested_area( +async def test_specifying_via_device_create( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - area_registry: ar.AreaRegistry, - mock_config_entry: MockConfigEntry, - initial_area: str | None, - device_area_id: str | None, - number_of_areas: int, + caplog: pytest.LogCaptureFixture, ) -> None: - """Verify that we can update the suggested area of a device. + """Test specifying a via_device and removal of the hub device.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) - Updating the suggested area of a device should not create a new area, nor should - it change the area_id of the device. - """ - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + via = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bla", "123")}, - suggested_area=initial_area, + identifiers={("hue", "0123")}, + manufacturer="manufacturer", + model="via", ) - assert entry.area_id == device_area_id - - suggested_area = "Pool" - - with patch.object(device_registry, "async_schedule_save") as mock_save: - updated_entry = device_registry.async_update_device( - entry.id, suggested_area=suggested_area - ) - # Check the device registry was not saved - assert mock_save.call_count == 0 - assert updated_entry != entry - assert updated_entry.area_id == device_area_id + light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), + ) - # Check we did not create an area - pool_area = area_registry.async_get_area_by_name(suggested_area) - assert pool_area is None - assert updated_entry.area_id == device_area_id - assert len(area_registry.areas) == number_of_areas + assert light.via_device_id == via.id - await hass.async_block_till_done() + device_registry.async_remove_device(via.id) + light = device_registry.async_get_device(identifiers={("hue", "456")}) + assert light.via_device_id is None - assert len(update_events) == 1 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, + # A device with a non existing via_device reference should create + light_via_nonexisting_parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "789")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "non_existing_123"), + ) + assert { + "calls `device_registry.async_get_or_create` " + "referencing a non existing `via_device` " + '("hue","non_existing_123")' in caplog.text } - - # Do not save or fire the event if the suggested - # area does not result in a change of area - # but still update the actual entry - with patch.object(device_registry, "async_schedule_save") as mock_save_2: - updated_entry = device_registry.async_update_device( - entry.id, suggested_area="Other" - ) - assert len(update_events) == 1 - assert mock_save_2.call_count == 0 - assert updated_entry != entry - assert updated_entry.area_id == device_area_id + assert light_via_nonexisting_parent_device is not None + assert light_via_nonexisting_parent_device.via_device_id is None + nonexisting_parent_device = device_registry.async_get_device( + identifiers={("hue", "non_existing_123")} + ) + assert nonexisting_parent_device is None -@pytest.mark.parametrize( - ( - "new_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), - [ - ( - None, - None, - None, - {}, - ), - # Config entry not disabled, device was disabled by config entry. - # Device not disabled when updated. - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - None, - {"disabled_by": dr.DeviceEntryDisabler.CONFIG_ENTRY}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - None, - None, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - ], -) -@pytest.mark.usefixtures("freezer") -async def test_update_add_config_entry_disabled_by( +async def test_specifying_via_device_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], + caplog: pytest.LogCaptureFixture, ) -> None: - """Check how the disabled_by flag is treated when adding a config entry.""" - config_entry_1 = MockConfigEntry(title=None) + """Test specifying a via_device and updating.""" + config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - title=None, disabled_by=new_config_entry_disabled_by - ) + config_entry_2 = MockConfigEntry() config_entry_2.add_to_hass(hass) - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, + + light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + name="Light", + via_device=("hue", "0123"), ) - assert entry.disabled_by == device_disabled_by_initial - entry2 = device_registry.async_update_device( - entry.id, add_config_entry_id=config_entry_2.entry_id + assert light.via_device_id is None + + via = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("hue", "0123")}, + manufacturer="manufacturer", + model="via", ) - assert entry2 == dr.DeviceEntry( - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, + light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), ) - await hass.async_block_till_done() + assert light.via_device_id == via.id + assert light.name == "Light" - assert len(update_events) == 2 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - } - | extra_changes, + # Try updating with a non existing via device + light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + name="New light", + via_device=("hue", "non_existing_abc"), + ) + assert { + "calls `device_registry.async_get_or_create` " + "referencing a non existing `via_device` " + '("hue","non_existing_123")' in caplog.text } + # Assert the name was updated correctly + assert light.via_device_id == via.id + assert light.name == "New light" -@pytest.mark.parametrize( - ( - "removed_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), - [ - # The non-disabled config entry is removed, device changed to - # disabled by config entry. - ( - None, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - # In this test, the device is in an invalid state: config entry disabled, - # device not disabled. After removing the config entry, the device is disabled - # by checking the remaining config entry. - ( - config_entries.ConfigEntryDisabler.USER, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - ], -) -@pytest.mark.usefixtures("freezer") -async def test_update_remove_config_entry_disabled_by( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - removed_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], +async def test_get_or_create_via_device_and_via_device_id_not_allowed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Check how the disabled_by flag is treated when removing a config entry.""" - config_entry_1 = MockConfigEntry( - title=None, disabled_by=removed_config_entry_disabled_by + """Passing both via_device and via_device_id is not allowed.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - title=None, disabled_by=config_entries.ConfigEntryDisabler.USER - ) - config_entry_2.add_to_hass(hass) - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, - ) - assert entry.disabled_by == device_disabled_by_initial - entry2 = device_registry.async_update_device( - entry.id, add_config_entry_id=config_entry_2.entry_id - ) - assert entry2.disabled_by == device_disabled_by_initial + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + via_device_id=via.id, + ) - entry3 = device_registry.async_update_device( - entry.id, remove_config_entry_id=config_entry_1.entry_id + # Passing only via_device_id is allowed + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device_id=via.id, ) + assert device.via_device_id == via.id - assert entry3 == dr.DeviceEntry( - config_entries={config_entry_2.entry_id}, - config_entries_subentries={config_entry_2.entry_id: {None}}, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, + # Passing only the deprecated via_device is still allowed (resolved to via_device_id) + device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device_2")}, + via_device=("hue", "via"), ) - - await hass.async_block_till_done() - - assert len(update_events) == 3 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - } - | extra_changes, - } + assert device_2.via_device_id == via.id -async def test_cleanup_device_registry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, +async def test_get_or_create_via_device_none( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test cleanup works.""" - config_entry = MockConfigEntry(domain="hue") + """`via_device=None` means "no via device"; combining it with via_device_id raises.""" + config_entry = MockConfigEntry() config_entry.add_to_hass(hass) - ghost_config_entry = MockConfigEntry() - ghost_config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) - d1 = device_registry.async_get_or_create( - identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id + # `via_device=None` alongside a via_device_id is contradictory and rejected + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + via_device_id=via.id, + ) + + # `via_device=None` on its own means no via device + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, ) - device_registry.async_get_or_create( - identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id + assert device.via_device_id is None + + # ... and it clears an existing via device on re-registration + linked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device_id=via.id, ) - d3 = device_registry.async_get_or_create( - identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id + assert linked.via_device_id == via.id + relinked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device=None, ) - device_registry.async_get_or_create( - identifiers={("something", "d4")}, config_entry_id=ghost_config_entry.entry_id + assert relinked.id == linked.id + assert relinked.via_device_id is None + + +async def test_via_device_prefers_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device resolves to the via device in the same config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # Two via devices share an identifier, one per config entry + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} ) - # Remove the config entry without triggering the normal cleanup - hass.config_entries._entries.pop(ghost_config_entry.entry_id) + via_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("hue", "via")} + ) + assert via_1.id != via_2.id - entity_registry.async_get_or_create("light", "hue", "e1", device_id=d1.id) - entity_registry.async_get_or_create("light", "hue", "e2", device_id=d1.id) - entity_registry.async_get_or_create("light", "hue", "e3", device_id=d3.id) + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_2.id - # Manual cleanup should detect the orphaned config entry - dr.async_cleanup(hass, device_registry, entity_registry) - assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None - assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None - assert device_registry.async_get_device(identifiers={("hue", "d3")}) is not None - assert device_registry.async_get_device(identifiers={("something", "d4")}) is None +async def test_via_device_falls_back_to_other_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device falls back to a via device in another config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # The via device only exists in entry_1 + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_1.id -async def test_cleanup_device_registry_removes_expired_orphaned_devices( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, + +async def test_via_device_prefers_same_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test cleanup removes expired orphaned devices.""" - config_entry = MockConfigEntry(domain="hue") - config_entry.add_to_hass(hass) + """The deprecated via_device prefers a via device from the same integration. + When no via device exists in the registering config entry, one from another config + entry of the same domain is preferred over an arbitrary other-domain match. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + other_domain_entry = MockConfigEntry(domain="deconz") + other_domain_entry.add_to_hass(hass) + same_domain_entry = MockConfigEntry(domain="hue") + same_domain_entry.add_to_hass(hass) + + # No via device in `entry`; the other-domain candidate is indexed first device_registry.async_get_or_create( - identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id + config_entry_id=other_domain_entry.entry_id, identifiers={("hue", "via")} ) - device_registry.async_get_or_create( - identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id + via_same_domain = device_registry.async_get_or_create( + config_entry_id=same_domain_entry.entry_id, identifiers={("hue", "via")} ) - device_registry.async_get_or_create( - identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), ) + assert device.via_device_id == via_same_domain.id - device_registry.async_clear_config_entry(config_entry.entry_id) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 - dr.async_cleanup(hass, device_registry, entity_registry) +async def test_loading_saving_data( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that we load/save data correctly.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry() + config_entry_3.add_to_hass(hass) + config_entry_4 = MockConfigEntry() + config_entry_4.add_to_hass(hass) + config_entry_5 = MockConfigEntry() + config_entry_5.add_to_hass(hass) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 3 + orig_via = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("hue", "0123")}, + manufacturer="manufacturer", + model="via", + name="Original Name", + sw_version="Orig SW 1", + entry_type=None, + ) - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 + orig_light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), + disabled_by=dr.DeviceEntryDisabler.USER, + ) - with patch("time.time", return_value=future_time): - dr.async_cleanup(hass, device_registry, entity_registry) + orig_light2 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "789")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), + ) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 + device_registry.async_remove_device(orig_light2.id) + orig_light3 = device_registry.async_get_or_create( + config_entry_id=config_entry_3.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + identifiers={("hue", "abc")}, + manufacturer="manufacturer", + model="light", + ) -async def test_cleanup_startup(hass: HomeAssistant) -> None: - """Test we run a cleanup on startup.""" - hass.set_state(CoreState.not_running) + device_registry.async_get_or_create( + config_entry_id=config_entry_4.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + identifiers={("abc", "123")}, + manufacturer="manufacturer", + model="light", + ) - with patch( - "homeassistant.helpers.device_registry.Debouncer.async_call" - ) as mock_call: - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() + device_registry.async_remove_device(orig_light3.id) - assert len(mock_call.mock_calls) == 1 + orig_light4 = device_registry.async_get_or_create( + config_entry_id=config_entry_3.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + identifiers={("hue", "abc")}, + manufacturer="manufacturer", + model="light", + entry_type=dr.DeviceEntryType.SERVICE, + ) + assert orig_light4.id == orig_light3.id -@pytest.mark.parametrize("load_registries", [False]) -async def test_cleanup_entity_registry_change( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test we run a cleanup when entity registry changes. + orig_kitchen_light = device_registry.async_get_or_create( + config_entry_id=config_entry_5.entry_id, + connections=set(), + identifiers={("hue", "999")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "0123"), + disabled_by=dr.DeviceEntryDisabler.USER, + suggested_area="Kitchen", + ) - Don't pre-load the registries as the debouncer will then not be waiting for - EVENT_ENTITY_REGISTRY_UPDATED events. - """ - dr.async_setup(hass) - await dr.async_load(hass) - await er.async_load(hass) - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) + # config_entry_4's device shares a connection with orig_light3 but belongs to a + # different config entry, so it is a separate device (identifiers/connections are + # unique per config entry) + assert len(device_registry.devices) == 5 + assert len(device_registry.deleted_devices) == 1 - entry = dev_reg.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + orig_via = device_registry.async_update_device( + orig_via.id, + area_id="mock-area-id", + name_by_user="mock-name-by-user", + labels={"mock-label1", "mock-label2"}, ) - with patch( - "homeassistant.helpers.device_registry.Debouncer.async_schedule_call" - ) as mock_call: - entity = ent_reg.async_get_or_create("light", "hue", "e1") - await hass.async_block_till_done() - assert len(mock_call.mock_calls) == 0 - - # Normal update does not trigger - ent_reg.async_update_entity(entity.entity_id, name="updated") - await hass.async_block_till_done() - assert len(mock_call.mock_calls) == 0 + # Now load written data in new registry + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + await registry2.async_load() - # Device ID update triggers - ent_reg.async_get_or_create("light", "hue", "e1", device_id=entry.id) - await hass.async_block_till_done() - assert len(mock_call.mock_calls) == 1 - - # Removal also triggers - ent_reg.async_remove(entity.entity_id) - await hass.async_block_till_done() - assert len(mock_call.mock_calls) == 2 + # Ensure same order + assert list(device_registry.devices) == list(registry2.devices) + assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) + new_via = registry2.async_get_device(identifiers={("hue", "0123")}) + new_light = registry2.async_get_device(identifiers={("hue", "456")}) + new_light4 = registry2.async_get_device(identifiers={("hue", "abc")}) -@pytest.mark.parametrize("initial_area", [None, "12345A"]) -@pytest.mark.usefixtures("freezer") -async def test_restore_device( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry_with_subentries: MockConfigEntry, - initial_area: str | None, -) -> None: - """Make sure device id is stable.""" - entry_id = mock_config_entry_with_subentries.entry_id - subentry_id = "mock-subentry-id-1-1" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=subentry_id, - configuration_url="http://config_url_orig.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_orig", - model="model_orig", - model_id="model_id_orig", - name="name_orig", - serial_number="serial_no_orig", - suggested_area="suggested_area_orig", - sw_version="version_orig", - via_device="via_device_id_orig", - ) + assert orig_via == new_via + assert orig_light == new_light + assert orig_light4 == new_light4 - # Apply user customizations - entry = device_registry.async_update_device( - entry.id, - area_id=initial_area, - disabled_by=dr.DeviceEntryDisabler.USER, - labels={"label1", "label2"}, - name_by_user="Test Friendly Name", - ) + # Ensure enums converted + for old, new in ( + (orig_via, new_via), + (orig_light, new_light), + (orig_light4, new_light4), + ): + assert old.disabled_by is new.disabled_by + assert old.entry_type is new.entry_type - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + # Ensure a save/load cycle does not keep suggested area + new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")}) + assert orig_kitchen_light.area_id == "kitchen" - device_registry.async_remove_device(entry.id) + orig_kitchen_light_without_suggested_area = device_registry.async_update_device( + orig_kitchen_light.id, suggested_area=None + ) + assert orig_kitchen_light_without_suggested_area.area_id == "kitchen" + assert orig_kitchen_light_without_suggested_area == new_kitchen_light - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - # This will create a new device - entry2 = device_registry.async_get_or_create( - config_entry_id=entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - assert entry2 == dr.DeviceEntry( - area_id=None, - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, - configuration_url=None, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, - created_at=utcnow(), - disabled_by=None, - entry_type=None, - hw_version=None, - id=ANY, - identifiers={("bridgeid", "4567")}, - labels={}, - manufacturer="manufacturer", - model="model", - model_id=None, - modified_at=utcnow(), - name_by_user=None, - name=None, - primary_config_entry=entry_id, - serial_number=None, - sw_version=None, - ) - # This will restore the original device, user customizations of - # area_id, disabled_by, labels and name_by_user will be restored - entry3 = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=subentry_id, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_new", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - name="name_new", - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", - via_device="via_device_id_new", - ) - assert entry3 == dr.DeviceEntry( - area_id=initial_area, - config_entries={entry_id}, - config_entries_subentries={entry_id: {subentry_id}}, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_new", - id=entry.id, - identifiers={("bridgeid", "0123")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new", - primary_config_entry=entry_id, - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", +async def test_no_unnecessary_changes( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: + """Make sure we do not consider devices changes.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, + identifiers={("hue", "456"), ("bla", "123")}, ) + with patch( + "homeassistant.helpers.device_registry.DeviceRegistry.async_schedule_save" + ) as mock_save: + entry2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, identifiers={("hue", "456")} + ) - assert entry.id == entry3.id - assert entry.id != entry2.id - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 + assert entry.id == entry2.id + assert len(mock_save.mock_calls) == 0 - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - await hass.async_block_till_done() +async def test_format_mac( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: + """Make sure we normalize mac addresses.""" + entry = 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")}, + ) + for mac in ("123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"): + test_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + assert test_entry.id == entry.id, mac + assert test_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + } - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "changes": { - "area_id": "suggested_area_orig", - "disabled_by": None, - "labels": set(), - "name_by_user": None, - }, - "device_id": entry.id, - } - assert update_events[2].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } - assert update_events[3].data == { - "action": "create", - "device_id": entry2.id, - } - assert update_events[4].data == { - "action": "create", - "device_id": entry3.id, - } + # This should not raise + for invalid in ( + "invalid_mac", + "123456ABCDEFG", # 1 extra char + "12:34:56:ab:cdef", # not enough : + "12:34:56:ab:cd:e:f", # too many : + "1234.56abcdef", # not enough . + "123.456.abc.def", # too many . + ): + invalid_mac_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, invalid)}, + ) + assert list(invalid_mac_entry.connections)[0][1] == invalid -@pytest.mark.parametrize( - ("device_disabled_by", "expected_disabled_by"), - [ - (None, None), - (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), - (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), - (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), - (UNDEFINED, None), - ], -) -@pytest.mark.usefixtures("freezer") -async def test_restore_migrated_device_disabled_by( +async def test_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, - device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None, - expected_disabled_by: dr.DeviceEntryDisabler | None, + freezer: FrozenDateTimeFactory, ) -> None: - """Check how the disabled_by flag is treated when restoring a device.""" - entry_id = mock_config_entry.entry_id + """Verify that we can update some attributes of a device.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + freezer.move_to(created_at) update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_orig.bla", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=None, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_orig", - model="model_orig", - model_id="model_id_orig", - name="name_orig", - serial_number="serial_no_orig", - suggested_area="suggested_area_orig", - sw_version="version_orig", - via_device="via_device_id_orig", + identifiers={("hue", "456"), ("bla", "123")}, ) + new_connections = {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} + new_identifiers = {("hue", "654"), ("bla", "321")} + assert not entry.area_id + assert not entry.labels + assert not entry.name_by_user + assert entry.created_at == created_at + assert entry.modified_at == created_at - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") + freezer.move_to(modified_at) + with patch.object(device_registry, "async_schedule_save") as mock_save: + updated_entry = device_registry.async_update_device( + entry.id, + area_id="12345A", + configuration_url="https://example.com/config", + disabled_by=dr.DeviceEntryDisabler.USER, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version", + labels={"label1", "label2"}, + manufacturer="Test Producer", + model="Test Model", + model_id="Test Model Name", + name_by_user="Test Friendly Name", + name="name", + new_connections=new_connections, + new_identifiers=new_identifiers, + serial_number="serial_no", + suggested_area="suggested_area", + sw_version="version", + via_device_id="98765B", + ) - device_registry.async_remove_device(entry.id) + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry == dr.DeviceEntry( + area_id="12345A", + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, + configuration_url="https://example.com/config", + connections={("mac", "65:43:21:fe:dc:ba")}, + created_at=created_at, + disabled_by=dr.DeviceEntryDisabler.USER, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version", + id=entry.id, + identifiers={("bla", "321"), ("hue", "654")}, + labels={"label1", "label2"}, + manufacturer="Test Producer", + model="Test Model", + model_id="Test Model Name", + modified_at=modified_at, + name_by_user="Test Friendly Name", + name="name", + serial_number="serial_no", + suggested_area="suggested_area", + sw_version="version", + via_device_id="98765B", + ) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 + assert device_registry.async_get_device(identifiers={("hue", "456")}) is None + assert device_registry.async_get_device(identifiers={("bla", "123")}) is None - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( - deleted_entry, disabled_by=UNDEFINED + assert ( + device_registry.async_get_device(identifiers={("hue", "654")}) == updated_entry + ) + assert ( + device_registry.async_get_device(identifiers={("bla", "321")}) == updated_entry ) - # This will restore the original device, user customizations of - # area_id, disabled_by, labels and name_by_user will be restored - entry3 = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by, - entry_type=None, - hw_version="hw_version_new", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - name="name_new", - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", - via_device="via_device_id_new", + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} + ) + is None ) - assert entry3 == dr.DeviceEntry( - area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=expected_disabled_by, - entry_type=None, - hw_version="hw_version_new", - id=entry.id, - identifiers={("bridgeid", "0123")}, - labels=set(), - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - modified_at=utcnow(), - name_by_user=None, - name="name_new", - primary_config_entry=entry_id, - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} + ) + == updated_entry ) - assert entry.id == entry3.id - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) + assert device_registry.async_get(updated_entry.id) is not None await hass.async_block_till_done() - assert len(update_events) == 3 + assert len(update_events) == 2 assert update_events[0].data == { "action": "create", "device_id": entry.id, } assert update_events[1].data == { - "action": "remove", + "action": "update", "device_id": entry.id, - "device": entry.dict_repr, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, + "changes": { + "area_id": None, + "connections": {("mac", "12:34:56:ab:cd:ef")}, + "configuration_url": None, + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "identifiers": {("bla", "123"), ("hue", "456")}, + "labels": set(), + "manufacturer": None, + "model": None, + "model_id": None, + "name": None, + "name_by_user": None, + "serial_number": None, + "suggested_area": None, + "sw_version": None, + "via_device_id": None, + }, } + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_connections=new_connections, + new_connections=new_connections, + ) + + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_identifiers=new_identifiers, + new_identifiers=new_identifiers, + ) @pytest.mark.parametrize( - ( - "config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_restored", - ), + ("initial_connections", "new_connections", "updated_connections"), [ - ( - None, - None, + ( # No connection -> single connection None, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, ), - # Config entry not disabled, device was disabled by config entry. - # Device not disabled when restored. - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, + ( # No connection -> double connection None, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, + ( # single connection -> no connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + set(), + set(), ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, + ( # single connection -> single connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, ), - # Config entry disabled, device not disabled. - # Device disabled by config entry when restored. - ( - config_entries.ConfigEntryDisabler.USER, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, + ( # single connection -> double connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, + ( # Double connection -> None + { + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + }, + set(), + set(), ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, + ( # Double connection -> single connection + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba")}, ), ], ) -@pytest.mark.usefixtures("freezer") -async def test_restore_disabled_by( - hass: HomeAssistant, +async def test_update_connection( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, - config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_restored: dr.DeviceEntryDisabler | None, + initial_connections: set[tuple[str, str]] | None, + new_connections: set[tuple[str, str]] | None, + updated_connections: set[tuple[str, str]] | None, ) -> None: - """Check how the disabled_by flag is treated when restoring a device.""" - entry_id = mock_config_entry.entry_id - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - await hass.config_entries.async_set_disabled_by( - mock_config_entry.entry_id, config_entry_disabled_by - ) + """Verify that we can update some attributes of a device.""" entry = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_orig.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_orig", - model="model_orig", - model_id="model_id_orig", - name="name_orig", - serial_number="serial_no_orig", - suggested_area="suggested_area_orig", - sw_version="version_orig", - via_device="via_device_id_orig", + config_entry_id=mock_config_entry.entry_id, + connections=initial_connections, + identifiers={("hue", "456"), ("bla", "123")}, ) - assert entry.disabled_by == device_disabled_by_initial + with patch.object(device_registry, "async_schedule_save") as mock_save: + updated_entry = device_registry.async_update_device( + entry.id, + new_connections=new_connections, + ) - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.connections == updated_connections + assert ( + device_registry.async_get_device(identifiers={("bla", "123")}) == updated_entry + ) - device_registry.async_remove_device(entry.id) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 +async def test_update_remove_config_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test removing a device's config entry deletes the device.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) - # This will restore the original device, user customizations of - # area_id, disabled_by, labels and name_by_user will be restored - entry3 = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_new.bla", + 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")}, - disabled_by=None, - entry_type=None, - hw_version="hw_version_new", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - name="name_new", - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", - via_device="via_device_id_new", - ) - assert entry3 == dr.DeviceEntry( - area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_restored, - entry_type=None, - hw_version="hw_version_new", - id=entry.id, identifiers={("bridgeid", "0123")}, - labels=set(), - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - modified_at=utcnow(), - name_by_user=None, - name="name_new", - primary_config_entry=entry_id, - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", ) + assert entry.config_entry_id == config_entry.entry_id - assert entry.id == entry3.id - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - - await hass.async_block_till_done() + # Removing the owning config entry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id + ) - assert len(update_events) == 3 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 -@pytest.mark.usefixtures("freezer") -async def test_restore_shared_device( +async def test_update_remove_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure device id is stable for shared devices.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test removing a device's config subentry deletes the device.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - ), + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_orig_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_orig_1", - model="model_orig_1", - model_id="model_id_orig_1", - name="name_orig_1", - serial_number="serial_no_orig_1", - suggested_area="suggested_area_orig_1", - sw_version="version_orig_1", - via_device="via_device_id_orig_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Add another config entry to the same device - device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_orig_2.bla", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_orig_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - name="name_orig_2", - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - via_device="via_device_id_orig_2", + identifiers={("bridgeid", "0123")}, ) + assert entry.config_subentry_id == "mock-subentry-id-1" - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Apply user customizations - updated_device = device_registry.async_update_device( + # Removing the owning config entry/subentry with no pending move deletes the device + updated = device_registry.async_update_device( entry.id, - area_id="12345A", - disabled_by=dr.DeviceEntryDisabler.USER, - labels={"label1", "label2"}, - name_by_user="Test Friendly Name", + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id="mock-subentry-id-1", ) - # Check device entry before we remove it - assert updated_device == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_orig_2", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_orig_2", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - ) + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 - device_registry.async_remove_device(entry.id) - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 +@pytest.mark.parametrize( + ("initial_area", "device_area_id", "number_of_areas"), + [ + (None, None, 0), + ("Living Room", "living_room", 1), + ], +) +async def test_update_suggested_area( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, + initial_area: str | None, + device_area_id: str | None, + number_of_areas: int, +) -> None: + """Verify that we can update the suggested area of a device. - # config_entry_1 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", + Updating the suggested area of a device should not create a new area, nor should + it change the area_id of the device. + """ + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = 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")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", + identifiers={("bla", "123")}, + suggested_area=initial_area, ) + assert entry.area_id == device_area_id - assert entry2 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id}, - config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) + suggested_area = "Pool" - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + with patch.object(device_registry, "async_schedule_save") as mock_save: + updated_entry = device_registry.async_update_device( + entry.id, suggested_area=suggested_area + ) - assert isinstance(entry2.config_entries, set) - assert isinstance(entry2.connections, set) - assert isinstance(entry2.identifiers, set) + # Check the device registry was not saved + assert mock_save.call_count == 0 + assert updated_entry != entry + assert updated_entry.area_id == device_area_id - # Remove the device again - device_registry.async_remove_device(entry.id) + # Check we did not create an area + pool_area = area_registry.async_get_area_by_name(suggested_area) + assert pool_area is None + assert updated_entry.area_id == device_area_id + assert len(area_registry.areas) == number_of_areas - # config_entry_2 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_new_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - name="name_new_2", - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - via_device="via_device_id_new_2", - ) + await hass.async_block_till_done() - assert entry3 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_new_2", - id=entry.id, - identifiers={("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_2", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - ) + assert len(update_events) == 1 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + # Do not save or fire the event if the suggested + # area does not result in a change of area + # but still update the actual entry + with patch.object(device_registry, "async_schedule_save") as mock_save_2: + updated_entry = device_registry.async_update_device( + entry.id, suggested_area="Other" + ) + assert len(update_events) == 1 + assert mock_save_2.call_count == 0 + assert updated_entry != entry + assert updated_entry.area_id == device_area_id - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - # Add config_entry_1 back to the restored device - entry4 = device_registry.async_get_or_create( +@pytest.mark.parametrize( + "device_disabled_by", + [ + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_add_config_entry_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + device_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when adding a config entry. + + A device is now owned by a single config entry: add_config_entry_id only records a + transient pending move (completed by a subsequent remove of the current owner), so on + its own it leaves the device - including its disabled_by flag - unchanged. + """ + config_entry_1 = MockConfigEntry(title=None) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry(title=None) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", + config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry4 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", + disabled_by=device_disabled_by, ) + assert entry.disabled_by == device_disabled_by - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 + entry2 = device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) - assert isinstance(entry4.config_entries, set) - assert isinstance(entry4.connections, set) - assert isinstance(entry4.identifiers, set) + # The device is unchanged: still owned by config_entry_1, same disabled_by + assert entry2.config_entry_id == config_entry_1.entry_id + assert entry2.config_subentry_id is None + assert entry2.disabled_by == device_disabled_by await hass.async_block_till_done() - assert len(update_events) == 8 + # The pending move is never stored, so no update event is fired + assert len(update_events) == 1 assert update_events[0].data == { "action": "create", "device_id": entry.id, } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - "configuration_url": "http://config_url_orig_1.bla", - "entry_type": dr.DeviceEntryType.SERVICE, - "hw_version": "hw_version_orig_1", - "identifiers": {("entry_123", "0123")}, - "manufacturer": "manufacturer_orig_1", - "model": "model_orig_1", - "model_id": "model_id_orig_1", - "name": "name_orig_1", - "serial_number": "serial_no_orig_1", - "suggested_area": "suggested_area_orig_1", - "sw_version": "version_orig_1", - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "area_id": "suggested_area_orig_1", - "disabled_by": None, - "labels": set(), - "name_by_user": None, - }, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": updated_device.dict_repr, - } - assert update_events[4].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[5].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[6].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[7].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_2.entry_id}, - "config_entries_subentries": {config_entry_2.entry_id: {None}}, - "configuration_url": "http://config_url_new_2.bla", - "entry_type": None, - "hw_version": "hw_version_new_2", - "identifiers": {("entry_234", "2345")}, - "manufacturer": "manufacturer_new_2", - "model": "model_new_2", - "model_id": "model_id_new_2", - "name": "name_new_2", - "serial_number": "serial_no_new_2", - "suggested_area": "suggested_area_new_2", - "sw_version": "version_new_2", - }, - } -async def test_get_or_create_empty_then_set_default_values( +@pytest.mark.parametrize( + ("device_disabled_by", "expected_disabled_by"), + [ + # An enabled device moved onto a disabled entry is disabled by CONFIG_ENTRY + (None, dr.DeviceEntryDisabler.CONFIG_ENTRY), + # An existing CONFIG_ENTRY / INTEGRATION / USER disable is preserved + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_remove_config_entry_disabled_by( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, + device_disabled_by: dr.DeviceEntryDisabler | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Test creating an entry, then setting default name, model, manufacturer.""" - entry = 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")}, - ) - assert entry.name is None - assert entry.model is None - assert entry.manufacturer is None + """Check how the disabled_by flag is treated when removing a config entry. + add_config_entry_id followed by remove_config_entry_id of the current owner moves the + device to the added config entry. The move re-evaluates disabled_by against the new + owning entry (like restoring a deleted device): an enabled device moved onto a + disabled entry becomes CONFIG_ENTRY-disabled, while a USER/INTEGRATION disable - or an + existing CONFIG_ENTRY disable - is kept. + """ + config_entry_1 = MockConfigEntry(title=None) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + title=None, disabled_by=config_entries.ConfigEntryDisabler.USER + ) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - default_name="default name 1", - default_model="default model 1", - default_manufacturer="default manufacturer 1", + disabled_by=device_disabled_by, ) - assert entry.name == "default name 1" - assert entry.model == "default model 1" - assert entry.manufacturer == "default manufacturer 1" + assert entry.disabled_by == device_disabled_by - entry = 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")}, - default_name="default name 2", - default_model="default model 2", - default_manufacturer="default manufacturer 2", + # add records a pending move, remove of the current owner performs it + device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) + entry3 = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry_1.entry_id ) - assert entry.name == "default name 1" - assert entry.model == "default model 1" - assert entry.manufacturer == "default manufacturer 1" + # The device moved to config_entry_2, disabled_by reflecting the new entry + assert entry3 is not None + assert entry3.config_entry_id == config_entry_2.entry_id + assert entry3.config_subentry_id is None + assert entry3.disabled_by == expected_disabled_by -async def test_get_or_create_empty_then_update( - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, + await hass.async_block_till_done() + + # create + the move update (the add on its own does not fire an event) + assert len(update_events) == 2 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + expected_changes: dict[str, Any] = {"config_entry_id": config_entry_1.entry_id} + if expected_disabled_by != device_disabled_by: + expected_changes["disabled_by"] = device_disabled_by + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": expected_changes, + } + + +async def test_move_to_enabled_config_entry_clears_config_entry_disable( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test creating an entry, then setting name, model, manufacturer.""" - entry = 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")}, + """Moving a device to an enabled config entry clears a CONFIG_ENTRY disable. + + The reverse of moving onto a disabled entry; a USER disable is preserved. + """ + disabled_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER ) - assert entry.name is None - assert entry.model is None - assert entry.manufacturer is None + disabled_entry.add_to_hass(hass) + enabled_entry = MockConfigEntry() + enabled_entry.add_to_hass(hass) - entry = 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")}, - name="name 1", - model="model 1", - manufacturer="manufacturer 1", + device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "1")}, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, ) - assert entry.name == "name 1" - assert entry.model == "model 1" - assert entry.manufacturer == "manufacturer 1" + device_registry.async_update_device( + device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved is not None + assert moved.config_entry_id == enabled_entry.entry_id + assert moved.disabled_by is None - entry = 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")}, - default_name="default name 1", - default_model="default model 1", - default_manufacturer="default manufacturer 1", + user_device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "2")}, + disabled_by=dr.DeviceEntryDisabler.USER, ) - assert entry.name == "name 1" - assert entry.model == "model 1" - assert entry.manufacturer == "manufacturer 1" + device_registry.async_update_device( + user_device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved_user = device_registry.async_update_device( + user_device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved_user is not None + assert moved_user.disabled_by is dr.DeviceEntryDisabler.USER -async def test_get_or_create_sets_default_values( - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, +async def test_move_to_config_entry_with_colliding_identity_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test creating an entry, then setting default name, model, manufacturer.""" - entry = 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")}, - default_name="default name 1", - default_model="default model 1", - default_manufacturer="default manufacturer 1", + """Moving a device onto a config entry that already has its identity raises. + + Identifiers and connections are unique per config entry, so a move must validate the + device's retained identity against the target entry instead of silently overwriting + the existing device's index slot. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} ) - assert entry.name == "default name 1" - assert entry.model == "default model 1" - assert entry.manufacturer == "default manufacturer 1" + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_2.entry_id + ) - entry = 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")}, - default_name="default name 2", - default_model="default model 2", - default_manufacturer="default manufacturer 2", + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_c = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, connections={mac} ) - assert entry.name == "default name 1" - assert entry.model == "default model 1" - assert entry.manufacturer == "default manufacturer 1" + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, connections={mac} + ) + with pytest.raises(dr.DeviceConnectionCollisionError): + device_registry.async_update_device( + device_c.id, new_config_entry_id=entry_2.entry_id + ) -async def test_verify_suggested_area_does_not_overwrite_area_id( - device_registry: dr.DeviceRegistry, - area_registry: ar.AreaRegistry, - mock_config_entry: MockConfigEntry, +async def test_add_identifier_keeps_other_config_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure suggested area does not override a set area id.""" - game_room_area = area_registry.async_create("Game Room") + """Adding an identifier does not delete a matching deleted device of another entry. - original_entry = 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={("bridgeid", "0123")}, - sw_version="sw-version", - name="name", - manufacturer="manufacturer", - model="model", + Deleted devices are per config entry now, so a device in entry A merging an + identifier must not wipe entry B's deleted-device metadata (its restore data). + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "a")} ) - entry = device_registry.async_update_device( - original_entry.id, area_id=game_room_area.id + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} ) + device_registry.async_update_device(device_b.id, name_by_user="Custom B") + device_b_id = device_b.id + device_registry.async_remove_device(device_b.id) - assert entry.area_id == game_room_area.id + # entry A's device merges the identifier entry B's deleted device also has + device_registry.async_update_device( + device_a.id, merge_identifiers={("test", "shared")} + ) - entry2 = 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={("bridgeid", "0123")}, - sw_version="sw-version", - name="name", - manufacturer="manufacturer", - model="model", - suggested_area="New Game Room", + # entry B's deleted device survives, so re-registering restores its id and metadata + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} ) - assert entry2.area_id == game_room_area.id - - -async def test_disable_config_entry_disables_devices( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we disable entities tied to a config entry.""" - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, - disabled_by=dr.DeviceEntryDisabler.USER, - ) - - assert not entry1.disabled - assert entry2.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert entry1.disabled - assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - entry2 = device_registry.async_get(entry2.id) - assert entry2.disabled - assert entry2.disabled_by is dr.DeviceEntryDisabler.USER - - await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - entry2 = device_registry.async_get(entry2.id) - assert entry2.disabled - assert entry2.disabled_by is dr.DeviceEntryDisabler.USER + assert restored_b.id == device_b_id + assert restored_b.name_by_user == "Custom B" -async def test_only_disable_device_if_all_config_entries_are_disabled( - hass: HomeAssistant, device_registry: dr.DeviceRegistry +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_remaps_via_device_id_to_split( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test that we only disable device if all related config entries are disabled.""" - config_entry1 = MockConfigEntry(domain="light") - config_entry1.add_to_hass(hass) - config_entry2 = MockConfigEntry(domain="light") - config_entry2.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert len(entry1.config_entries) == 2 - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry1.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry2.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() + """A child's via_device_id is remapped to a live parent split. - entry1 = device_registry.async_get(entry1.id) - assert entry1.disabled - assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - - await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled + To the split in the child's own config entry when the parent spanned it, otherwise to + one of the parent's splits - never left dangling on the removed composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="dom_c") + entry_c.add_to_hass(hass) + + def _device(id_: str, entries: list[str], identifiers, via: str | None) -> dict: + return { + "area_id": None, + "config_entries": entries, + "config_entries_subentries": {entry: [None] for entry in entries}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": id_, + "identifiers": identifiers, + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entries[0], + "serial_number": None, + "sw_version": None, + "via_device_id": via, + } + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + _device( + "parent000000000000000000000000", + [entry_a.entry_id, entry_b.entry_id], + [["dom_a", "p"], ["dom_b", "p"]], + None, + ), + _device( + "child0000000000000000000000000", + [entry_a.entry_id], + [["dom_a", "c"]], + "parent000000000000000000000000", + ), + # child in a config entry the parent does not span + _device( + "childc000000000000000000000000", + [entry_c.entry_id], + [["dom_c", "c"]], + "parent000000000000000000000000", + ), + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) -@pytest.mark.parametrize( - ("configuration_url", "expectation"), - [ - ("http://localhost", nullcontext()), - ("http://localhost:8123", nullcontext()), - ("https://example.com", nullcontext()), - ("http://localhost/config", nullcontext()), - ("http://localhost:8123/config", nullcontext()), - ("https://example.com/config", nullcontext()), - ("homeassistant://config", nullcontext()), - (URL("http://localhost"), nullcontext()), - (URL("http://localhost:8123"), nullcontext()), - (URL("https://example.com"), nullcontext()), - (URL("http://localhost/config"), nullcontext()), - (URL("http://localhost:8123/config"), nullcontext()), - (URL("https://example.com/config"), nullcontext()), - (URL("homeassistant://config"), nullcontext()), - (None, nullcontext()), - ("http://", pytest.raises(ValueError)), - ("https://", pytest.raises(ValueError)), - ("gopher://localhost", pytest.raises(ValueError)), - ("homeassistant://", pytest.raises(ValueError)), - (URL("http://"), pytest.raises(ValueError)), - (URL("https://"), pytest.raises(ValueError)), - (URL("gopher://localhost"), pytest.raises(ValueError)), - (URL("homeassistant://"), pytest.raises(ValueError)), - # Exception implements __str__ - (Exception("https://example.com"), nullcontext()), - (Exception("https://"), pytest.raises(ValueError)), - (Exception(), pytest.raises(ValueError)), - ], -) -async def test_device_info_configuration_url_validation( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - configuration_url: str | URL | None, - expectation: AbstractContextManager, -) -> None: - """Test configuration URL of device info is properly validated.""" - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) + # The parent splits (fresh split ids, not the old composite id) + parent_a = registry.async_get_device(identifiers={("dom_a", "p")}) + parent_b = registry.async_get_device(identifiers={("dom_b", "p")}) + assert parent_a is not None + assert parent_b is not None + assert parent_a.config_entry_id == entry_a.entry_id + assert parent_a.id != "parent000000000000000000000000" - with expectation: - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - identifiers={("something", "1234")}, - name="name", - configuration_url=configuration_url, - ) + # The child in entry_a points at the parent's entry_a split + child = registry.async_get_device(identifiers={("dom_a", "c")}) + assert child is not None + assert child.via_device_id == parent_a.id - update_device = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - identifiers={("something", "5678")}, - name="name", - ) - with expectation: - device_registry.async_update_device( - update_device.id, configuration_url=configuration_url - ) + # The child in entry_c, which the parent did not span, points at one of the parent's + # splits rather than the removed composite id + child_c = registry.async_get_device(identifiers={("dom_c", "c")}) + assert child_c is not None + assert child_c.via_device_id in {parent_a.id, parent_b.id} +@pytest.mark.parametrize("load_registries", [False]) @pytest.mark.parametrize( - "field", - [ - "hw_version", - "manufacturer", - "model", - "model_id", - "serial_number", - "sw_version", - ], -) -@pytest.mark.parametrize( - ("value", "stored_value", "expected_log"), + ("composite_disabled_by", "expected_split_enabled", "expected_split_disabled"), [ - (1.0, "1.0", "passes a non-string value of type float as {field}"), - ((1, 2), "(1, 2)", "passes a non-string value of type tuple as {field}"), - ("hw-1", "hw-1", ""), - (None, None, ""), + pytest.param( + None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY, id="enabled_composite" + ), + pytest.param( + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + id="user_disabled", + ), + pytest.param( + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + id="config_entry_disabled", + ), ], ) -async def test_device_info_string_field_validation( +async def test_migration_split_disabled_by_follows_config_entry( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, - field: str, - value: Any, - stored_value: str | None, - expected_log: str, + hass_storage: dict[str, Any], + composite_disabled_by: dr.DeviceEntryDisabler | None, + expected_split_enabled: dr.DeviceEntryDisabler | None, + expected_split_disabled: dr.DeviceEntryDisabler, ) -> None: - """Test string device info fields are validated and coerced.""" - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - identifiers={("something", "1234")}, - name="name", - **{field: value}, - ) - assert getattr(entry, field) == stored_value + """A split's disabled_by follows its single owning config entry's disabled state. - update_device = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - identifiers={("something", "5678")}, - name="name", + A composite spanning an enabled and a disabled config entry copies its disabled_by to + both splits; each split is then reconciled against its own entry - the split owned by + the disabled entry becomes CONFIG_ENTRY disabled (a USER disable is preserved), while + the split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared. + """ + entry_enabled = MockConfigEntry(domain="dom_a") + entry_enabled.add_to_hass(hass) + entry_disabled = MockConfigEntry( + domain="dom_b", disabled_by=config_entries.ConfigEntryDisabler.USER ) - updated = device_registry.async_update_device(update_device.id, **{field: value}) - assert updated is not None - assert getattr(updated, field) == stored_value - - assert expected_log.format(field=field) in caplog.text - + entry_disabled.add_to_hass(hass) -@pytest.mark.parametrize("load_registries", [False]) -async def test_loading_invalid_configuration_url_from_storage( - hass: HomeAssistant, - hass_storage: dict[str, Any], - mock_config_entry: MockConfigEntry, -) -> None: - """Test loading stored devices with an invalid URL.""" hass_storage[dr.STORAGE_KEY] = { - "version": dr.STORAGE_VERSION_MAJOR, - "minor_version": dr.STORAGE_VERSION_MINOR, + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, "data": { "devices": [ { "area_id": None, - "config_entries": ["1234"], - "config_entries_subentries": {"1234": [None]}, - "configuration_url": "invalid", + "config_entries": [ + entry_enabled.entry_id, + entry_disabled.entry_id, + ], + "config_entries_subentries": { + entry_enabled.entry_id: [None], + entry_disabled.entry_id: [None], + }, + "configuration_url": None, "connections": [], - "created_at": "2024-01-01T00:00:00+00:00", - "disabled_by": None, - "entry_type": dr.DeviceEntryType.SERVICE, + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": composite_disabled_by, + "entry_type": None, "hw_version": None, - "id": "abcdefghijklm", - "identifiers": [["serial", "123456ABCDEF"]], + "id": "composite00000000000000000000", + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], "labels": [], "manufacturer": None, "model": None, + "name": None, "model_id": None, - "modified_at": "2024-02-01T00:00:00+00:00", + "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, - "name": None, - "primary_config_entry": "1234", + "primary_config_entry": entry_enabled.entry_id, "serial_number": None, "sw_version": None, "via_device_id": None, @@ -5025,733 +4744,2722 @@ async def test_loading_invalid_configuration_url_from_storage( "deleted_devices": [], }, } - dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) - assert len(registry.devices) == 1 - entry = registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={("serial", "123456ABCDEF")}, - ) - assert entry.configuration_url == "invalid" + split_enabled = registry.async_get_device(identifiers={("dom_a", "x")}) + split_disabled = registry.async_get_device(identifiers={("dom_b", "x")}) + assert split_enabled is not None + assert split_disabled is not None + assert split_enabled.config_entry_id == entry_enabled.entry_id + assert split_disabled.config_entry_id == entry_disabled.entry_id + # The split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared + assert split_enabled.disabled_by is expected_split_enabled + # The split owned by the disabled entry follows that entry (USER preserved) + assert split_disabled.disabled_by is expected_split_disabled -async def test_removing_labels( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + +@pytest.mark.parametrize("load_registries", [False]) +async def test_disabled_by_not_reconciled_without_composite_split( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Make sure we can clear labels.""" - config_entry = MockConfigEntry() - config_entry.add_to_hass(hass) - 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")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry = device_registry.async_update_device(entry.id, labels={"label1", "label2"}) + """disabled_by is reconciled only for split composites, not other migrated devices. - device_registry.async_clear_label_id("label1") - entry_cleared_label1 = device_registry.async_get_device({("bridgeid", "0123")}) + A 1.12 -> 1.13 migration that splits no composite does not touch a device whose stored + disabled_by does not match its config entry. + """ + entry = MockConfigEntry( + domain="dom_a", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry.add_to_hass(hass) - device_registry.async_clear_label_id("label2") - entry_cleared_label2 = device_registry.async_get_device({("bridgeid", "0123")}) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device000000000000000000000000", + "identifiers": [["dom_a", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": 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 entry_cleared_label1 - assert entry_cleared_label2 - assert entry != entry_cleared_label1 - assert entry != entry_cleared_label2 - assert entry_cleared_label1 != entry_cleared_label2 - assert entry.labels == {"label1", "label2"} - assert entry_cleared_label1.labels == {"label2"} - assert not entry_cleared_label2.labels + device = registry.async_get_device(identifiers={("dom_a", "x")}) + assert device is not None + # The reconcile is gated on a composite split, so disabled_by is left as stored + assert device.disabled_by is None -async def test_removing_labels_deleted_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry +@pytest.mark.parametrize("config_entry_disabled", [False, True]) +@pytest.mark.parametrize( + "initial_disabled_by", + [ + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, + ], +) +async def test_migrate_device_disabled_by_matches_runtime( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + initial_disabled_by: dr.DeviceEntryDisabler | None, + config_entry_disabled: bool, ) -> None: - """Make sure we can clear labels.""" - config_entry = MockConfigEntry() - config_entry.add_to_hass(hass) - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + """The migration dict reconcile matches async_config_entry_disabled_by_changed. + + _migrate_device_disabled_by reimplements the runtime helper on stored data, so for + every combination of device disabled_by and config entry state both must agree. + """ + config_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + if config_entry_disabled + else None ) - entry1 = device_registry.async_update_device(entry1.id, labels={"label1", "label2"}) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, - identifiers={("bridgeid", "1234")}, - manufacturer="manufacturer", - model="model", + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} ) - entry2 = device_registry.async_update_device(entry2.id, labels={"label3"}) + # Explicit disabled_by bypasses async_update_device's own reconciliation + device_registry.async_update_device(device.id, disabled_by=initial_disabled_by) - device_registry.async_remove_device(entry1.id) - device_registry.async_remove_device(entry2.id) + # Runtime helper on the loaded registry + dr.async_config_entry_disabled_by_changed(device_registry, config_entry) + runtime_result = device_registry.async_get(device.id).disabled_by - device_registry.async_clear_label_id("label1") - entry1_cleared_label1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, + # Migration helper on the stored representation + stored = {"disabled_by": initial_disabled_by} + dr._migrate_device_disabled_by(stored, config_entry_disabled) + + assert stored["disabled_by"] == runtime_result + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_composite_lineage_not_restored_after_remove( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A migrated split loses its composite lineage once removed. + + The deleted device does not carry composite data, so re-registering the split makes a + plain device that no longer resolves from the pre-migration composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + + old_id = "composite00000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": old_id, + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.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) + + split_a = registry.async_get_device(identifiers={("dom_a", "x")}) + assert split_a is not None + assert split_a.composite_device_id == old_id + + # Remove the split; the deleted device does not carry the composite lineage + registry.async_remove_device(split_a.id) + + # Re-registering reuses the deleted device's id but drops the composite lineage + restored = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("dom_a", "x")} ) + assert restored.id == split_a.id + assert restored.composite_device_id is None + assert restored not in registry.async_get_devices_for_composite_device_id(old_id) - device_registry.async_remove_device(entry1.id) - device_registry.async_clear_label_id("label2") - entry1_cleared_label2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, +async def test_cleanup_device_registry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleanup works.""" + config_entry = MockConfigEntry(domain="hue") + config_entry.add_to_hass(hass) + ghost_config_entry = MockConfigEntry() + ghost_config_entry.add_to_hass(hass) + + d1 = device_registry.async_get_or_create( + identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id ) - entry2_restored = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, - identifiers={("bridgeid", "1234")}, + device_registry.async_get_or_create( + identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id + ) + d3 = device_registry.async_get_or_create( + identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id + ) + device_registry.async_get_or_create( + identifiers={("something", "d4")}, config_entry_id=ghost_config_entry.entry_id ) + # Remove the config entry without triggering the normal cleanup + hass.config_entries._entries.pop(ghost_config_entry.entry_id) - assert entry1_cleared_label1 - assert entry1_cleared_label2 - assert entry1 != entry1_cleared_label1 - assert entry1 != entry1_cleared_label2 - assert entry1_cleared_label1 != entry1_cleared_label2 - assert entry1.labels == {"label1", "label2"} - assert entry1_cleared_label1.labels == {"label2"} - assert not entry1_cleared_label2.labels - assert entry2 != entry2_restored - assert entry2_restored.labels == {"label3"} + entity_registry.async_get_or_create("light", "hue", "e1", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e2", device_id=d1.id) + entity_registry.async_get_or_create("light", "hue", "e3", device_id=d3.id) + # Manual cleanup should detect the orphaned config entry + dr.async_cleanup(hass, device_registry, entity_registry) -async def test_entries_for_label( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None + assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None + assert device_registry.async_get_device(identifiers={("hue", "d3")}) is not None + assert device_registry.async_get_device(identifiers={("something", "d4")}) is None + + +async def test_cleanup_device_registry_removes_expired_orphaned_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: - """Test getting device entries by label.""" - config_entry = MockConfigEntry() + """Test cleanup removes expired orphaned devices.""" + config_entry = MockConfigEntry(domain="hue") config_entry.add_to_hass(hass) device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:00")}, - identifiers={("bridgeid", "0000")}, - manufacturer="manufacturer", - model="model", - ) - entry_1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:23")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry_1 = device_registry.async_update_device(entry_1.id, labels={"label1"}) - entry_2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:56")}, - identifiers={("bridgeid", "0456")}, - manufacturer="manufacturer", - model="model", + identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id ) - entry_2 = device_registry.async_update_device(entry_2.id, labels={"label2"}) - entry_1_and_2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:89")}, - identifiers={("bridgeid", "0789")}, - manufacturer="manufacturer", - model="model", + device_registry.async_get_or_create( + identifiers={("hue", "d2")}, config_entry_id=config_entry.entry_id ) - entry_1_and_2 = device_registry.async_update_device( - entry_1_and_2.id, labels={"label1", "label2"} + device_registry.async_get_or_create( + identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id ) - entries = dr.async_entries_for_label(device_registry, "label1") - assert len(entries) == 2 - assert entries == [entry_1, entry_1_and_2] + device_registry.async_clear_config_entry(config_entry.entry_id) + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 3 - entries = dr.async_entries_for_label(device_registry, "label2") - assert len(entries) == 2 - assert entries == [entry_2, entry_1_and_2] + dr.async_cleanup(hass, device_registry, entity_registry) - assert not dr.async_entries_for_label(device_registry, "unknown") - assert not dr.async_entries_for_label(device_registry, "") + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 3 + future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 -@pytest.mark.parametrize( - ( - "translation_key", - "translations", - "placeholders", - "expected_device_name", - ), - [ - (None, None, None, "Device Bla"), - ( - "test_device", - { + with patch("time.time", return_value=future_time): + dr.async_cleanup(hass, device_registry, entity_registry) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 0 + + +async def test_cleanup_startup(hass: HomeAssistant) -> None: + """Test we run a cleanup on startup.""" + hass.set_state(CoreState.not_running) + + with patch( + "homeassistant.helpers.device_registry.Debouncer.async_call" + ) as mock_call: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_call.mock_calls) == 1 + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_cleanup_entity_registry_change( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we run a cleanup when entity registry changes. + + Don't pre-load the registries as the debouncer will then not be waiting for + EVENT_ENTITY_REGISTRY_UPDATED events. + """ + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + entry = dev_reg.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + with patch( + "homeassistant.helpers.device_registry.Debouncer.async_schedule_call" + ) as mock_call: + entity = ent_reg.async_get_or_create("light", "hue", "e1") + await hass.async_block_till_done() + assert len(mock_call.mock_calls) == 0 + + # Normal update does not trigger + ent_reg.async_update_entity(entity.entity_id, name="updated") + await hass.async_block_till_done() + assert len(mock_call.mock_calls) == 0 + + # Device ID update triggers + ent_reg.async_get_or_create("light", "hue", "e1", device_id=entry.id) + await hass.async_block_till_done() + assert len(mock_call.mock_calls) == 1 + + # Removal also triggers + ent_reg.async_remove(entity.entity_id) + await hass.async_block_till_done() + assert len(mock_call.mock_calls) == 2 + + +@pytest.mark.parametrize("initial_area", [None, "12345A"]) +@pytest.mark.usefixtures("freezer") +async def test_restore_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry_with_subentries: MockConfigEntry, + initial_area: str | None, +) -> None: + """Make sure device id is stable.""" + entry_id = mock_config_entry_with_subentries.entry_id + subentry_id = "mock-subentry-id-1-1" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + # Apply user customizations + entry = device_registry.async_update_device( + entry.id, + area_id=initial_area, + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"label1", "label2"}, + name_by_user="Test Friendly Name", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + # This will create a new device + entry2 = device_registry.async_get_or_create( + config_entry_id=entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + assert entry2 == dr.DeviceEntry( + area_id=None, + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url=None, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version=None, + id=ANY, + identifiers={("bridgeid", "4567")}, + labels={}, + manufacturer="manufacturer", + model="model", + model_id=None, + modified_at=utcnow(), + name_by_user=None, + name=None, + serial_number=None, + sw_version=None, + ) + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id=initial_area, + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=dr.DeviceEntryDisabler.USER, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels={"label1", "label2"}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user="Test Friendly Name", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert entry.id != entry2.id + assert len(device_registry.devices) == 2 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 5 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "changes": { + "area_id": "suggested_area_orig", + "disabled_by": None, + "labels": set(), + "name_by_user": None, + }, + "device_id": entry.id, + } + assert update_events[2].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[3].data == { + "action": "create", + "device_id": entry2.id, + } + assert update_events[4].data == { + "action": "create", + "device_id": entry3.id, + } + + +@pytest.mark.parametrize( + ("device_disabled_by", "expected_disabled_by"), + [ + (None, None), + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), + (UNDEFINED, None), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_device_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring a device.""" + entry_id = mock_config_entry.entry_id + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + deleted_entry = device_registry.deleted_devices[entry.id] + device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry, disabled_by=UNDEFINED + ) + + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_orig", + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=expected_disabled_by, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels=set(), + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[2].data == { + "action": "create", + "device_id": entry3.id, + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_restored", + ), + [ + ( + None, + None, + None, + ), + # Config entry not disabled, device was disabled by config entry. + # Device not disabled when restored. + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + ), + # Config entry disabled, device not disabled. + # Device disabled by config entry when restored. + ( + config_entries.ConfigEntryDisabler.USER, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_restored: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring a device.""" + entry_id = mock_config_entry.entry_id + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + await hass.config_entries.async_set_disabled_by( + mock_config_entry.entry_id, config_entry_disabled_by + ) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + assert entry.disabled_by == device_disabled_by_initial + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=None, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_orig", + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_restored, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels=set(), + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[2].data == { + "action": "create", + "device_id": entry3.id, + } + + +async def test_get_or_create_empty_then_set_default_values( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an entry, then setting default name, model, manufacturer.""" + entry = 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")}, + ) + assert entry.name is None + assert entry.model is None + assert entry.manufacturer is None + + entry = 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")}, + default_name="default name 1", + default_model="default model 1", + default_manufacturer="default manufacturer 1", + ) + assert entry.name == "default name 1" + assert entry.model == "default model 1" + assert entry.manufacturer == "default manufacturer 1" + + entry = 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")}, + default_name="default name 2", + default_model="default model 2", + default_manufacturer="default manufacturer 2", + ) + assert entry.name == "default name 1" + assert entry.model == "default model 1" + assert entry.manufacturer == "default manufacturer 1" + + +async def test_get_or_create_empty_then_update( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an entry, then setting name, model, manufacturer.""" + entry = 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")}, + ) + assert entry.name is None + assert entry.model is None + assert entry.manufacturer is None + + entry = 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")}, + name="name 1", + model="model 1", + manufacturer="manufacturer 1", + ) + assert entry.name == "name 1" + assert entry.model == "model 1" + assert entry.manufacturer == "manufacturer 1" + + entry = 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")}, + default_name="default name 1", + default_model="default model 1", + default_manufacturer="default manufacturer 1", + ) + assert entry.name == "name 1" + assert entry.model == "model 1" + assert entry.manufacturer == "manufacturer 1" + + +async def test_get_or_create_sets_default_values( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an entry, then setting default name, model, manufacturer.""" + entry = 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")}, + default_name="default name 1", + default_model="default model 1", + default_manufacturer="default manufacturer 1", + ) + assert entry.name == "default name 1" + assert entry.model == "default model 1" + assert entry.manufacturer == "default manufacturer 1" + + entry = 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")}, + default_name="default name 2", + default_model="default model 2", + default_manufacturer="default manufacturer 2", + ) + assert entry.name == "default name 1" + assert entry.model == "default model 1" + assert entry.manufacturer == "default manufacturer 1" + + +async def test_verify_suggested_area_does_not_overwrite_area_id( + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Make sure suggested area does not override a set area id.""" + game_room_area = area_registry.async_create("Game Room") + + original_entry = 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={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + ) + entry = device_registry.async_update_device( + original_entry.id, area_id=game_room_area.id + ) + + assert entry.area_id == game_room_area.id + + entry2 = 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={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + suggested_area="New Game Room", + ) + assert entry2.area_id == game_room_area.id + + +async def test_disable_config_entry_disables_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that we disable entities tied to a config entry.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + entry1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + assert not entry1.disabled + assert entry2.disabled + + await hass.config_entries.async_set_disabled_by( + config_entry.entry_id, config_entries.ConfigEntryDisabler.USER + ) + await hass.async_block_till_done() + + entry1 = device_registry.async_get(entry1.id) + assert entry1.disabled + assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + entry2 = device_registry.async_get(entry2.id) + assert entry2.disabled + assert entry2.disabled_by is dr.DeviceEntryDisabler.USER + + await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) + await hass.async_block_till_done() + + entry1 = device_registry.async_get(entry1.id) + assert not entry1.disabled + entry2 = device_registry.async_get(entry2.id) + assert entry2.disabled + assert entry2.disabled_by is dr.DeviceEntryDisabler.USER + + +@pytest.mark.parametrize( + ("configuration_url", "expectation"), + [ + ("http://localhost", nullcontext()), + ("http://localhost:8123", nullcontext()), + ("https://example.com", nullcontext()), + ("http://localhost/config", nullcontext()), + ("http://localhost:8123/config", nullcontext()), + ("https://example.com/config", nullcontext()), + ("homeassistant://config", nullcontext()), + (URL("http://localhost"), nullcontext()), + (URL("http://localhost:8123"), nullcontext()), + (URL("https://example.com"), nullcontext()), + (URL("http://localhost/config"), nullcontext()), + (URL("http://localhost:8123/config"), nullcontext()), + (URL("https://example.com/config"), nullcontext()), + (URL("homeassistant://config"), nullcontext()), + (None, nullcontext()), + ("http://", pytest.raises(ValueError)), + ("https://", pytest.raises(ValueError)), + ("gopher://localhost", pytest.raises(ValueError)), + ("homeassistant://", pytest.raises(ValueError)), + (URL("http://"), pytest.raises(ValueError)), + (URL("https://"), pytest.raises(ValueError)), + (URL("gopher://localhost"), pytest.raises(ValueError)), + (URL("homeassistant://"), pytest.raises(ValueError)), + # Exception implements __str__ + (Exception("https://example.com"), nullcontext()), + (Exception("https://"), pytest.raises(ValueError)), + (Exception(), pytest.raises(ValueError)), + ], +) +async def test_device_info_configuration_url_validation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + configuration_url: str | URL | None, + expectation: AbstractContextManager, +) -> None: + """Test configuration URL of device info is properly validated.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + + with expectation: + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + identifiers={("something", "1234")}, + name="name", + configuration_url=configuration_url, + ) + + update_device = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + identifiers={("something", "5678")}, + name="name", + ) + with expectation: + device_registry.async_update_device( + update_device.id, configuration_url=configuration_url + ) + + +@pytest.mark.parametrize( + "field", + [ + "hw_version", + "manufacturer", + "model", + "model_id", + "serial_number", + "sw_version", + ], +) +@pytest.mark.parametrize( + ("value", "stored_value", "expected_log"), + [ + (1.0, "1.0", "passes a non-string value of type float as {field}"), + ((1, 2), "(1, 2)", "passes a non-string value of type tuple as {field}"), + ("hw-1", "hw-1", ""), + (None, None, ""), + ], +) +async def test_device_info_string_field_validation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + field: str, + value: Any, + stored_value: str | None, + expected_log: str, +) -> None: + """Test string device info fields are validated and coerced.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + identifiers={("something", "1234")}, + name="name", + **{field: value}, + ) + assert getattr(entry, field) == stored_value + + update_device = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + identifiers={("something", "5678")}, + name="name", + ) + updated = device_registry.async_update_device(update_device.id, **{field: value}) + assert updated is not None + assert getattr(updated, field) == stored_value + + assert expected_log.format(field=field) in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_invalid_configuration_url_from_storage( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading stored devices with an invalid URL.""" + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_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": "invalid", + "connections": [], + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": dr.DeviceEntryType.SERVICE, + "hw_version": None, + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "2024-02-01T00:00:00+00:00", + "name_by_user": None, + "name": None, + "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 + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.configuration_url == "invalid" + + +async def test_removing_labels( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we can clear labels.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + 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")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry = device_registry.async_update_device(entry.id, labels={"label1", "label2"}) + + device_registry.async_clear_label_id("label1") + entry_cleared_label1 = device_registry.async_get_device({("bridgeid", "0123")}) + + device_registry.async_clear_label_id("label2") + entry_cleared_label2 = device_registry.async_get_device({("bridgeid", "0123")}) + + assert entry_cleared_label1 + assert entry_cleared_label2 + assert entry != entry_cleared_label1 + assert entry != entry_cleared_label2 + assert entry_cleared_label1 != entry_cleared_label2 + assert entry.labels == {"label1", "label2"} + assert entry_cleared_label1.labels == {"label2"} + assert not entry_cleared_label2.labels + + +async def test_removing_labels_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we can clear labels.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + entry1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry1 = device_registry.async_update_device(entry1.id, labels={"label1", "label2"}) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + identifiers={("bridgeid", "1234")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_update_device(entry2.id, labels={"label3"}) + + device_registry.async_remove_device(entry1.id) + device_registry.async_remove_device(entry2.id) + + device_registry.async_clear_label_id("label1") + entry1_cleared_label1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + ) + + device_registry.async_remove_device(entry1.id) + + device_registry.async_clear_label_id("label2") + entry1_cleared_label2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + ) + entry2_restored = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + identifiers={("bridgeid", "1234")}, + ) + + assert entry1_cleared_label1 + assert entry1_cleared_label2 + assert entry1 != entry1_cleared_label1 + assert entry1 != entry1_cleared_label2 + assert entry1_cleared_label1 != entry1_cleared_label2 + assert entry1.labels == {"label1", "label2"} + assert entry1_cleared_label1.labels == {"label2"} + assert not entry1_cleared_label2.labels + assert entry2 != entry2_restored + assert entry2_restored.labels == {"label3"} + + +async def test_entries_for_label( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test getting device entries by label.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:00")}, + identifiers={("bridgeid", "0000")}, + manufacturer="manufacturer", + model="model", + ) + entry_1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:23")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry_1 = device_registry.async_update_device(entry_1.id, labels={"label1"}) + entry_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:56")}, + identifiers={("bridgeid", "0456")}, + manufacturer="manufacturer", + model="model", + ) + entry_2 = device_registry.async_update_device(entry_2.id, labels={"label2"}) + entry_1_and_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:89")}, + identifiers={("bridgeid", "0789")}, + manufacturer="manufacturer", + model="model", + ) + entry_1_and_2 = device_registry.async_update_device( + entry_1_and_2.id, labels={"label1", "label2"} + ) + + entries = dr.async_entries_for_label(device_registry, "label1") + assert len(entries) == 2 + assert entries == [entry_1, entry_1_and_2] + + entries = dr.async_entries_for_label(device_registry, "label2") + assert len(entries) == 2 + assert entries == [entry_2, entry_1_and_2] + + assert not dr.async_entries_for_label(device_registry, "unknown") + assert not dr.async_entries_for_label(device_registry, "") + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "expected_device_name", + ), + [ + (None, None, None, "Device Bla"), + ( + "test_device", + { "en": {"component.test.device.test_device.name": "English device"}, }, None, "English device", ), - ( - "test_device", - { - "en": { - "component.test.device.test_device.name": ( - "{placeholder} English dev" - ) - }, - }, - {"placeholder": "special"}, - "special English dev", + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": ( + "{placeholder} English dev" + ) + }, + }, + {"placeholder": "special"}, + "special English dev", + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": ( + "English dev {placeholder}" + ) + }, + }, + {"placeholder": "special"}, + "English dev special", + ), + ], +) +async def test_device_name_translation_placeholders( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + expected_device_name: str | None, +) -> None: + """Test device name when the device name translation has placeholders.""" + + def async_get_cached_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + with patch( + "homeassistant.helpers.device_registry.translation.async_get_cached_translations", + side_effect=async_get_cached_translations, + ): + entry1 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device Bla", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + assert entry1.name == expected_device_name + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "release_channel", + "expectation", + "expected_error", + ), + [ + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": ( + "{placeholder} English dev {2ndplaceholder}" + ) + }, + }, + {"placeholder": "special"}, + ReleaseChannel.STABLE, + nullcontext(), + ( + "has translation placeholders '{'placeholder': 'special'}' which do " + "not match the name '{placeholder} English dev {2ndplaceholder}'" + ), + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": ( + "{placeholder} English ent {2ndplaceholder}" + ) + }, + }, + {"placeholder": "special"}, + ReleaseChannel.BETA, + pytest.raises( + HomeAssistantError, match="Missing placeholder '2ndplaceholder'" + ), + "", + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": ( + "{placeholder} English dev" + ) + }, + }, + None, + ReleaseChannel.STABLE, + nullcontext(), + ( + "has translation placeholders '{}' which do " + "not match the name '{placeholder} English dev'" + ), + ), + ], +) +async def test_device_name_translation_placeholders_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + release_channel: ReleaseChannel, + expectation: AbstractContextManager, + expected_error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device name has placeholder issuess.""" + + def async_get_cached_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + with ( + patch( + "homeassistant.helpers.device_registry.translation.async_get_cached_translations", + side_effect=async_get_cached_translations, + ), + patch( + "homeassistant.helpers.device_registry.get_release_channel", + return_value=release_channel, ), - ( - "test_device", - { - "en": { - "component.test.device.test_device.name": ( - "English dev {placeholder}" - ) - }, - }, - {"placeholder": "special"}, - "English dev special", + expectation, + ): + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device Bla", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + assert expected_error in caplog.text + + +async def test_async_get_or_create_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_get_or_create raises when called from wrong thread.""" + + with pytest.raises( + RuntimeError, + match=( + "Detected code that calls" + " device_registry._async_update_device" + " from a thread." ), - ], -) -async def test_device_name_translation_placeholders( + ): + await hass.async_add_executor_job( + partial( + 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=set(), + manufacturer="manufacturer", + model="model", + ) + ) + + +async def test_async_remove_device_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_remove_device raises when called from wrong thread.""" + 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=set(), + manufacturer="manufacturer", + model="model", + ) + + with pytest.raises( + RuntimeError, + match=( + "Detected code that calls" + " device_registry.async_remove_device" + " from a thread." + ), + ): + await hass.async_add_executor_job( + device_registry.async_remove_device, device.id + ) + + +async def test_device_registry_connections_collision( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test connection collisions in the device registry.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "none")}, + manufacturer="manufacturer", + model="model", + ) + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "none")}, + manufacturer="manufacturer", + model="model", + ) + + assert device1.id == device2.id + + device3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + + # Attempt to merge connection for device3 with the same + # connection that already exists in device1 + with pytest.raises( + HomeAssistantError, match=f"Connections.*already registered.*{device1.id}" + ): + device_registry.async_update_device( + device3.id, + merge_connections={ + (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), + (dr.CONNECTION_NETWORK_MAC, "none"), + }, + ) + + # Attempt to add new connections for device3 with the same + # connection that already exists in device1 + with pytest.raises( + HomeAssistantError, match=f"Connections.*already registered.*{device1.id}" + ): + device_registry.async_update_device( + device3.id, + new_connections={ + (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), + (dr.CONNECTION_NETWORK_MAC, "none"), + }, + ) + + device3_refetched = device_registry.async_get(device3.id) + assert device3_refetched.connections == set() + assert device3_refetched.identifiers == {("bridgeid", "0123")} + + device1_refetched = device_registry.async_get(device1.id) + assert device1_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")} + assert device1_refetched.identifiers == set() + + device2_refetched = device_registry.async_get(device2.id) + assert device2_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")} + assert device2_refetched.identifiers == set() + + assert device2_refetched.id == device1_refetched.id + assert len(device_registry.devices) == 2 + + # Attempt to implicitly merge connection for device3 with the same + # connection that already exists in device1 + device4 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + connections={ + (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), + (dr.CONNECTION_NETWORK_MAC, "none"), + }, + ) + assert len(device_registry.devices) == 2 + assert device4.id in (device1.id, device3.id) + + device3_refetched = device_registry.async_get(device3.id) + device1_refetched = device_registry.async_get(device1.id) + assert not device1_refetched.connections.isdisjoint(device3_refetched.connections) + + +async def test_device_registry_identifiers_collision( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test identifiers collisions in the device registry.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + + assert device1.id == device2.id + + device3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + + # Attempt to merge identifiers for device3 with the same + # connection that already exists in device1 + with pytest.raises( + HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}" + ): + device_registry.async_update_device( + device3.id, merge_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")} + ) + + # Attempt to add new identifiers for device3 with the same + # connection that already exists in device1 + with pytest.raises( + HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}" + ): + device_registry.async_update_device( + device3.id, new_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")} + ) + + device3_refetched = device_registry.async_get(device3.id) + assert device3_refetched.connections == set() + assert device3_refetched.identifiers == {("bridgeid", "4567")} + + device1_refetched = device_registry.async_get(device1.id) + assert device1_refetched.connections == set() + assert device1_refetched.identifiers == {("bridgeid", "0123")} + + device2_refetched = device_registry.async_get(device2.id) + assert device2_refetched.connections == set() + assert device2_refetched.identifiers == {("bridgeid", "0123")} + + assert device2_refetched.id == device1_refetched.id + assert len(device_registry.devices) == 2 + + # Attempt to implicitly merge identifiers for device3 with the same + # connection that already exists in device1 + device4 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "4567"), ("bridgeid", "0123")}, + ) + assert len(device_registry.devices) == 2 + assert device4.id in (device1.id, device3.id) + + device3_refetched = device_registry.async_get(device3.id) + device1_refetched = device_registry.async_get(device1.id) + assert not device1_refetched.identifiers.isdisjoint(device3_refetched.identifiers) + + +async def test_device_registry_deleted_device_collision( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test update collisions with deleted devices in the device registry.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(device1.id) + assert len(device_registry.deleted_devices) == 1 + + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 1 + + device_registry.async_update_device( + device2.id, + merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + ) + assert len(device_registry.deleted_devices) == 0 + + +async def test_update_device_no_connections_or_identifiers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - translation_key: str | None, - translations: dict[str, str] | None, - placeholders: dict[str, str] | None, - expected_device_name: str | None, ) -> None: - """Test device name when the device name translation has placeholders.""" + """Test updating a device clearing connections and identifiers.""" + mock_config_entry = MockConfigEntry(domain="mqtt", title=None) + mock_config_entry.add_to_hass(hass) - def async_get_cached_translations( - hass: HomeAssistant, - language: str, - category: str, - integrations: Iterable[str] | None = None, - config_flow: bool | None = None, - ) -> dict[str, Any]: - """Return all backend translations.""" - return translations[language] + 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={("bridgeid", "0123")}, + ) + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + device.id, new_connections=set(), new_identifiers=set() + ) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - with patch( - "homeassistant.helpers.device_registry.translation.async_get_cached_translations", - side_effect=async_get_cached_translations, - ): - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - name="Device Bla", - translation_key=translation_key, - translation_placeholders=placeholders, + +async def test_connections_validator() -> None: + """Test checking connections validator.""" + with pytest.raises(ValueError, match="Invalid mac address format"): + dr.DeviceEntry( + config_entry_id="mock-config-entry", + connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}, ) - assert entry1.name == expected_device_name -@pytest.mark.parametrize( - ( - "translation_key", - "translations", - "placeholders", - "release_channel", - "expectation", - "expected_error", - ), - [ - ( - "test_device", - { - "en": { - "component.test.device.test_device.name": ( - "{placeholder} English dev {2ndplaceholder}" - ) - }, - }, - {"placeholder": "special"}, - ReleaseChannel.STABLE, - nullcontext(), - ( - "has translation placeholders '{'placeholder': 'special'}' which do " - "not match the name '{placeholder} English dev {2ndplaceholder}'" - ), - ), - ( - "test_device", - { - "en": { - "component.test.device.test_device.name": ( - "{placeholder} English ent {2ndplaceholder}" - ) - }, - }, - {"placeholder": "special"}, - ReleaseChannel.BETA, - pytest.raises( - HomeAssistantError, match="Missing placeholder '2ndplaceholder'" - ), - "", - ), - ( - "test_device", - { - "en": { - "component.test.device.test_device.name": ( - "{placeholder} English dev" - ) - }, - }, - None, - ReleaseChannel.STABLE, - nullcontext(), - ( - "has translation placeholders '{}' which do " - "not match the name '{placeholder} English dev'" - ), - ), - ], -) -async def test_device_name_translation_placeholders_errors( +async def test_suggested_area_deprecation( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - translation_key: str | None, - translations: dict[str, str] | None, - placeholders: dict[str, str] | None, - release_channel: ReleaseChannel, - expectation: AbstractContextManager, - expected_error: str, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: - """Test device name has placeholder issuess.""" + """Make sure we do not duplicate entries.""" + entry = 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={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + suggested_area="Game Room", + ) - def async_get_cached_translations( - hass: HomeAssistant, - language: str, - category: str, - integrations: Iterable[str] | None = None, - config_flow: bool | None = None, - ) -> dict[str, Any]: - """Return all backend translations.""" - return translations[language] + game_room_area = area_registry.async_get_area_by_name("Game Room") + assert game_room_area is not None + assert len(area_registry.areas) == 1 - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - with ( - patch( - "homeassistant.helpers.device_registry.translation.async_get_cached_translations", - side_effect=async_get_cached_translations, - ), - patch( - "homeassistant.helpers.device_registry.get_release_channel", - return_value=release_channel, - ), - expectation, - ): - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - name="Device Bla", - translation_key=translation_key, - translation_placeholders=placeholders, - ) + assert len(device_registry.devices) == 1 + assert entry.area_id == game_room_area.id + assert entry.suggested_area == "Game Room" - assert expected_error in caplog.text + assert ( + "The deprecated function suggested_area was called. It will be removed in " + "HA Core 2026.9. Use code which ignores suggested_area instead" + ) in caplog.text + + device_registry.async_update_device(entry.id, suggested_area="TV Room") + + assert ( + "Detected code that passes a suggested_area to device_registry.async_update " + "device. This will stop working in Home Assistant 2026.9.0, please report " + "this issue" + ) in caplog.text + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +async def test_single_config_entry_and_compat_properties( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device has a single config entry; the deprecated shims reflect it.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id is None + assert device.config_entries == {entry.entry_id} + assert device.config_entries_subentries == {entry.entry_id: {None}} + assert device.primary_config_entry == entry.entry_id + + +async def test_identifiers_unique_per_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The same identifier under two config entries yields two devices.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("shared", "1")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("shared", "1")} + ) + + assert device_a.id != device_b.id + + # Scoped lookup returns the owning device + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("shared", "1")} + ).id + == device_a.id + ) + assert ( + _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("shared", "1")} + ).id + == device_b.id + ) -async def test_async_get_or_create_thread_safety( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, +async def test_collision_only_within_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test async_get_or_create raises when called from wrong thread.""" + """A collision is raised only for two devices of the same config entry.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) - with pytest.raises( - RuntimeError, - match=( - "Detected code that calls" - " device_registry._async_update_device" - " from a thread." - ), - ): - await hass.async_add_executor_job( - partial( - 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=set(), - manufacturer="manufacturer", - model="model", - ) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + other = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "2")} + ) + + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + other.id, merge_identifiers={("domain_a", "1")} ) + assert device_registry.async_get(device.id) is not None -async def test_async_remove_device_thread_safety( +async def test_remove_shadowed_collision_keeps_index_consistent( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Removing a device that shadows a same-entry collision keeps the index consistent. + + allow_collisions lets a device absorb an identifier another device of the same config + entry holds, shadowing it in the index. When a second config entry also shares that + identifier, removing the shadowed device then the indexed one must not delete the wrong + slot or raise KeyError on the mapping the second entry keeps. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "1")} + ) + shadowed = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "2")} + ) + # The second config entry keeps its own slot for the shared identifier + other_entry_device = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "2")} + ) + # allow_collisions lets `device` absorb the shadowed device's identifier + device_registry._async_update_device( + device.id, merge_identifiers={("test", "2")}, allow_collisions=True + ) + assert device_registry.async_get(device.id).identifiers == { + ("test", "1"), + ("test", "2"), + } + assert shadowed.id in device_registry.devices + + # Remove the shadowed device, then the indexed one - neither must raise + device_registry.async_remove_device(shadowed.id) + device_registry.async_remove_device(device.id) + + # The second config entry's device is still reachable by the shared identifier + assert ( + device_registry.async_get_device(identifiers={("test", "2")}) + is other_entry_device + ) + + +@pytest.mark.parametrize( + ("identity", "merge_kwarg", "merge_extra", "error"), + [ + pytest.param( + {"identifiers": {("test", "shared")}}, + "merge_identifiers", + {("test", "extra")}, + dr.DeviceIdentifierCollisionError, + id="identifiers", + ), + pytest.param( + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}}, + "merge_connections", + {(dr.CONNECTION_NETWORK_MAC, "ab:cd:ef:12:34:56")}, + dr.DeviceConnectionCollisionError, + id="connections", + ), + ], +) +async def test_move_with_merge_validates_retained_identity( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, + identity: dict[str, set[tuple[str, str]]], + merge_kwarg: str, + merge_extra: set[tuple[str, str]], + error: type[Exception], ) -> None: - """Test async_remove_device raises when called from wrong thread.""" + """A move that also merges must validate the retained identity against the target. + + The merged additions are validated, but the retained old identity must be too, or the + move silently overwrites the target entry's index slot for a device already there. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) 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=set(), - manufacturer="manufacturer", - model="model", + config_entry_id=entry_a.entry_id, **identity ) + # entry_b already owns a device with the same identity + device_registry.async_get_or_create(config_entry_id=entry_b.entry_id, **identity) - with pytest.raises( - RuntimeError, - match=( - "Detected code that calls" - " device_registry.async_remove_device" - " from a thread." - ), - ): - await hass.async_add_executor_job( - device_registry.async_remove_device, device.id + # Moving device to entry_b retains its identity, which collides with entry_b's + # existing device, so the move must raise rather than silently shadow it. + with pytest.raises(error): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + **{merge_kwarg: merge_extra}, ) -async def test_device_registry_connections_collision( +async def test_move_two_calls_add_then_remove( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test connection collisions in the device registry.""" - config_entry = MockConfigEntry() - config_entry.add_to_hass(hass) + """Test add_config_entry_id records a pending move; the later remove performs it.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) - device1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "none")}, - manufacturer="manufacturer", - model="model", + # add alone does nothing yet + device_registry.async_update_device(device.id, add_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_a.entry_id + + # remove of the current owner performs the pending move + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id ) - device2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "none")}, - manufacturer="manufacturer", - model="model", + moved = device_registry.async_get(device.id) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + + +async def test_move_new_config_entry_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test new_config_entry_id moves the device immediately.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} ) - assert device1.id == device2.id + device_registry.async_update_device(device.id, new_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_b.entry_id - device3 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + +async def test_move_new_and_add_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test mixing new_config_entry_id with add/remove raises.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} ) - # Attempt to merge connection for device3 with the same - # connection that already exists in device1 - with pytest.raises( - HomeAssistantError, match=f"Connections.*already registered.*{device1.id}" - ): + with pytest.raises(HomeAssistantError, match="Can't combine"): device_registry.async_update_device( - device3.id, - merge_connections={ - (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), - (dr.CONNECTION_NETWORK_MAC, "none"), - }, + device.id, + new_config_entry_id=entry_b.entry_id, + add_config_entry_id=entry_b.entry_id, ) - # Attempt to add new connections for device3 with the same - # connection that already exists in device1 + +async def test_async_get_or_create_unknown_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get_or_create raises for an unknown config entry.""" with pytest.raises( - HomeAssistantError, match=f"Connections.*already registered.*{device1.id}" + HomeAssistantError, + match="Can't link device to unknown config entry unknown-config-entry", ): - device_registry.async_update_device( - device3.id, - new_connections={ - (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), - (dr.CONNECTION_NETWORK_MAC, "none"), - }, + device_registry.async_get_or_create( + config_entry_id="unknown-config-entry", identifiers={("bridgeid", "0123")} ) - device3_refetched = device_registry.async_get(device3.id) - assert device3_refetched.connections == set() - assert device3_refetched.identifiers == {("bridgeid", "0123")} - device1_refetched = device_registry.async_get(device1.id) - assert device1_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")} - assert device1_refetched.identifiers == set() +@pytest.mark.parametrize( + ("make_update_kwargs", "error_match"), + [ + pytest.param( + lambda entry: {"add_config_entry_id": "unknown-config-entry"}, + "Can't link device to unknown config entry unknown-config-entry", + id="add-unknown-config-entry", + ), + pytest.param( + lambda entry: {"add_config_subentry_id": "mock-subentry-id-2"}, + "Can't add config subentry without specifying config entry", + id="add-subentry-without-config-entry", + ), + pytest.param( + lambda entry: { + "add_config_entry_id": entry.entry_id, + "add_config_subentry_id": "unknown-subentry", + }, + "has no subentry unknown-subentry", + id="add-unknown-subentry", + ), + pytest.param( + lambda entry: {"remove_config_subentry_id": "mock-subentry-id-1"}, + "Can't remove config subentry without specifying config entry", + id="remove-subentry-without-config-entry", + ), + pytest.param( + lambda entry: {"new_config_entry_id": "unknown-config-entry"}, + "Can't move device to unknown config entry unknown-config-entry", + id="new-unknown-config-entry", + ), + pytest.param( + lambda entry: {"new_config_subentry_id": "unknown-subentry"}, + "has no subentry unknown-subentry", + id="new-unknown-subentry", + ), + pytest.param( + lambda entry: { + "new_config_entry_id": entry.entry_id, + "add_config_entry_id": entry.entry_id, + }, + "Can't combine new_config_entry_id or new_config_subentry_id", + id="combine-new-and-add", + ), + ], +) +async def test_update_device_config_entry_grammar_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + make_update_kwargs: Callable[[MockConfigEntry], dict[str, Any]], + error_match: str, +) -> None: + """The config-entry/subentry mutation grammar validates its arguments.""" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) - device2_refetched = device_registry.async_get(device2.id) - assert device2_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")} - assert device2_refetched.identifiers == set() + with pytest.raises(HomeAssistantError, match=error_match): + device_registry.async_update_device(device.id, **make_update_kwargs(entry)) - assert device2_refetched.id == device1_refetched.id - assert len(device_registry.devices) == 2 - # Attempt to implicitly merge connection for device3 with the same - # connection that already exists in device1 - device4 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, +async def test_move_device_to_config_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device can be moved to another subentry of its config entry. + + Immediately via new_config_subentry_id, or deferred via a pending move + (add_config_entry_id + add_config_subentry_id, completed by removing the current + owner). There is no subentry-only deferred move - add_config_subentry_id and + remove_config_subentry_id without a config entry raise (see + test_update_device_config_entry_grammar_errors). + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", identifiers={("bridgeid", "0123")}, - connections={ - (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), - (dr.CONNECTION_NETWORK_MAC, "none"), - }, ) - assert len(device_registry.devices) == 2 - assert device4.id in (device1.id, device3.id) - device3_refetched = device_registry.async_get(device3.id) - device1_refetched = device_registry.async_get(device1.id) - assert not device1_refetched.connections.isdisjoint(device3_refetched.connections) + # new_config_subentry_id moves the device immediately + moved = device_registry.async_update_device( + device.id, new_config_subentry_id="mock-subentry-id-2" + ) + assert moved.config_entry_id == entry.entry_id + assert moved.config_subentry_id == "mock-subentry-id-2" + + # Deferred move: adding the (same) config entry with the target subentry records a + # pending move; it does not move the device on its own + device_registry.async_update_device( + device.id, + add_config_entry_id=entry.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + assert ( + device_registry.async_get(device.id).config_subentry_id == "mock-subentry-id-2" + ) + # Removing the current owner performs the pending move to the target subentry + moved_back = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + assert moved_back is not None + assert moved_back.config_subentry_id == "mock-subentry-id-1" + + +async def test_move_device_to_config_entry_and_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A deferred move can target another config entry and one of its subentries.""" + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-b", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("bridgeid", "0123")} + ) + + # The pending move carries the (config entry, subentry) pair + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_b.entry_id, + add_config_subentry_id="mock-subentry-id-b", + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + assert moved.config_subentry_id == "mock-subentry-id-b" + + +async def test_pending_move_overwritten_by_later_add( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A later add_config_entry_id / add_config_subentry_id overwrites the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Each add records a pending move, overwriting the previous one: first a subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + # ... a later add to the same entry overwrites just the subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2", + ) + # ... a later add to a different entry overwrites the entry (subentry resets to None) + device_registry.async_update_device(device.id, add_config_entry_id=entry_3.entry_id) + + # None of the adds moved the device + assert device_registry.async_get(device.id).config_entry_id == entry_1.entry_id + + # Removing the owner performs the last recorded pending move + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_3.entry_id + assert moved.config_subentry_id is None -async def test_device_registry_identifiers_collision( +async def test_new_config_entry_id_clears_pending_move( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test identifiers collisions in the device registry.""" - config_entry = MockConfigEntry() - config_entry.add_to_hass(hass) + """An immediate new_config_entry_id move clears an earlier pending move. - device1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + Otherwise removing the new owner would perform the stale deferred move instead of + deleting the device, which has no other config entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} ) - device2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + + # Record a pending move to entry_2, then immediately move the device to entry_3 + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(device.id, new_config_entry_id=entry_3.entry_id) + assert device_registry.async_get(device.id)._pending_move is None + + # Removing the new owner deletes the device rather than performing the stale move + assert ( + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_3.entry_id + ) + is None ) + assert device_registry.async_get(device.id) is None - assert device1.id == device2.id - device3 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", +async def test_pending_move_canceled_by_cross_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from a different integration than the one that armed the move cancels it. + + Otherwise an incidental add_config_entry_id (e.g. device_tracker attaching a shared + MAC) would hijack the owning integration's later cleanup and move the device instead + of deleting it. + """ + entry_owner = MockConfigEntry(domain="owner") + entry_owner.add_to_hass(hass) + entry_target = MockConfigEntry(domain="attacher") + entry_target.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_owner.entry_id, identifiers={("test", "1")} ) - # Attempt to merge identifiers for device3 with the same - # connection that already exists in device1 - with pytest.raises( - HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}" - ): + # The "attacher" integration arms a deferred move to its own entry + with patch.object(dr, "_current_integration_domain", return_value="attacher"): device_registry.async_update_device( - device3.id, merge_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")} + device.id, add_config_entry_id=entry_target.entry_id ) + assert ( + device_registry.async_get(device.id)._pending_move.origin_domain == "attacher" + ) - # Attempt to add new identifiers for device3 with the same - # connection that already exists in device1 - with pytest.raises( - HomeAssistantError, match=f"Identifiers.*already registered.*{device1.id}" - ): - device_registry.async_update_device( - device3.id, new_identifiers={("bridgeid", "0123"), ("bridgeid", "8888")} + # The owning integration later removes its entry - a different domain, so the stale + # move is canceled and the device is deleted rather than transferred. + with patch.object(dr, "_current_integration_domain", return_value="owner"): + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_owner.entry_id ) + assert result is None + assert device_registry.async_get(device.id) is None - device3_refetched = device_registry.async_get(device3.id) - assert device3_refetched.connections == set() - assert device3_refetched.identifiers == {("bridgeid", "4567")} - - device1_refetched = device_registry.async_get(device1.id) - assert device1_refetched.connections == set() - assert device1_refetched.identifiers == {("bridgeid", "0123")} - - device2_refetched = device_registry.async_get(device2.id) - assert device2_refetched.connections == set() - assert device2_refetched.identifiers == {("bridgeid", "0123")} - - assert device2_refetched.id == device1_refetched.id - assert len(device_registry.devices) == 2 - # Attempt to implicitly merge identifiers for device3 with the same - # connection that already exists in device1 - device4 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "4567"), ("bridgeid", "0123")}, +async def test_pending_move_completed_by_same_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from the same integration that armed the move completes it.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} ) - assert len(device_registry.devices) == 2 - assert device4.id in (device1.id, device3.id) - device3_refetched = device_registry.async_get(device3.id) - device1_refetched = device_registry.async_get(device1.id) - assert not device1_refetched.identifiers.isdisjoint(device3_refetched.identifiers) + with patch.object(dr, "_current_integration_domain", return_value="mover"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_2.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert device_registry.async_get(device.id) is moved -async def test_device_registry_deleted_device_collision( +async def test_composite_move_clears_sibling_pending_moves( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test update collisions with deleted devices in the device registry.""" - config_entry = MockConfigEntry() - config_entry.add_to_hass(hass) + """Completing one split's move clears the pending move on its composite siblings. - device1 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, - manufacturer="manufacturer", - model="model", + Arming add_config_entry_id on a composite fans out to every split; once one split + moves to the target, the others must not also move there and collide. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + entry_target = MockConfigEntry(domain="test") + entry_target.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id ) - assert len(device_registry.deleted_devices) == 0 - device_registry.async_remove_device(device1.id) - assert len(device_registry.deleted_devices) == 1 + # Arm a deferred move on the composite id: fans out to both splits + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + old_id, add_config_entry_id=entry_target.entry_id + ) + assert device_registry.async_get(device_1.id)._pending_move is not None + assert device_registry.async_get(device_2.id)._pending_move is not None - device2 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + # Complete the move on split 1; split 2's pending move must be cleared + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + device_1.id, remove_config_entry_id=entry_1.entry_id + ) + assert ( + device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id ) - assert len(device_registry.deleted_devices) == 1 + assert device_registry.async_get(device_2.id)._pending_move is None - device_registry.async_update_device( - device2.id, - merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, - ) - assert len(device_registry.deleted_devices) == 0 + # Split 2's own removal now deletes it instead of colliding on the shared identifier + with patch.object(dr, "_current_integration_domain", return_value="test"): + assert ( + device_registry.async_update_device( + device_2.id, remove_config_entry_id=entry_2.entry_id + ) + is None + ) + assert device_registry.async_get(device_2.id) is None -async def test_primary_config_entry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, +async def test_add_and_remove_config_entry_in_one_call( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test the primary integration field.""" - mock_config_entry_1 = MockConfigEntry(domain="mqtt", title=None) - mock_config_entry_1.add_to_hass(hass) - mock_config_entry_2 = MockConfigEntry(title=None) - mock_config_entry_2.add_to_hass(hass) - mock_config_entry_3 = MockConfigEntry(title=None) - mock_config_entry_3.add_to_hass(hass) - mock_config_entry_4 = MockConfigEntry(domain="matter", title=None) - mock_config_entry_4.add_to_hass(hass) - - # Create device without model name etc, config entry will not be marked primary + """add_config_entry_id and remove_config_entry_id of the owner move in a single call.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} ) - assert device.primary_config_entry is None - # Set model, mqtt config entry will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model", + # Adding the new entry/subentry and removing the current owner in one call moves at once + moved = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + remove_config_entry_id=entry_1.entry_id, ) - assert device.primary_config_entry == mock_config_entry_1.entry_id + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert moved.config_subentry_id == "mock-subentry-id-1" + + await hass.async_block_till_done() + assert len(update_events) == 2 + assert update_events[1].data == { + "action": "update", + "device_id": device.id, + "changes": { + "config_entry_id": entry_1.entry_id, + "config_subentry_id": None, + }, + } - # New config entry with model will be promoted to primary + +async def test_remove_non_owner_config_entry_keeps_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """remove_config_entry_id of a non-owning entry does not perform the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 2", + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - # New config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", + # Add a pending move to entry_2, but remove a config entry the device does not own + result = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + remove_config_entry_id=entry_3.entry_id, ) - assert device.primary_config_entry == mock_config_entry_2.entry_id + # The device is neither moved nor removed: only removing the owner performs the move + assert result is not None + assert result.config_entry_id == entry_1.entry_id - # New matter config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_4.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", + # The pending move to entry_2 was still recorded; removing the owner now performs it + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id ) - assert device.primary_config_entry == mock_config_entry_2.entry_id + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id - # Remove the primary config entry - device = device_registry.async_update_device( - device.id, - remove_config_entry_id=mock_config_entry_2.entry_id, + +@pytest.mark.parametrize("load_registries", [False]) +async def test_reregistration_replaces_composite_identifiers( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """First re-registration replaces the copied identifiers with the provided ones.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} ) - assert device.primary_config_entry is None + assert split_a.has_composite_identifiers is True - # Create new - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - manufacturer="manufacturer", - model="model", + reregistered = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} ) - assert device.primary_config_entry == mock_config_entry_1.entry_id + assert reregistered.id == split_a.id + assert reregistered.identifiers == {("domain_a", "1")} # domain_b copy pruned + # assert the copied composite connection is cleared + assert reregistered.connections == set() + assert reregistered.has_composite_identifiers is False -async def test_update_device_no_connections_or_identifiers( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_returns_restored_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test updating a device clearing connections and identifiers.""" - mock_config_entry = MockConfigEntry(domain="mqtt", title=None) - mock_config_entry.add_to_hass(hass) + """Test async_get on the legacy id returns a merged, on-demand composite.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) - 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={("bridgeid", "0123")}, + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.id == COMPOSITE_ID + assert composite.config_entries == {entry_a.entry_id, entry_b.entry_id} + assert composite.config_entries_subentries == { + entry_a.entry_id: {None}, + entry_b.entry_id: {None}, + } + assert composite.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert composite.serial_number == "SERIAL" + + # Invisible to membership, enumeration and identifier search + assert COMPOSITE_ID not in device_registry.devices + assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()} + assert ( + device_registry.async_get_device(identifiers={("domain_a", "1")}).id + != COMPOSITE_ID ) - with pytest.raises(HomeAssistantError): - device_registry.async_update_device( - device.id, new_connections=set(), new_identifiers=set() - ) -async def test_connections_validator() -> None: - """Test checking connections validator.""" - with pytest.raises(ValueError, match="Invalid mac address format"): - dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) +@pytest.mark.parametrize("load_registries", [False]) +async def test_restored_composite_preserves_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The restored composite reports the pre-migration composite's former primary. + The composite's primary_config_entry is recorded on each split device + (composite_primary_config_entry) so the restored composite can report it, even when + it is not the first split. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's primary is entry_b, which is not its first config entry + storage = _composite_device_storage(entry_a, entry_b) + storage["data"]["devices"][0]["primary_config_entry"] = entry_b.entry_id + hass_storage[dr.STORAGE_KEY] = storage -async def test_suggested_area_deprecation( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - area_registry: ar.AreaRegistry, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + + # The former primary (entry_b) is preserved, even though it is not the first split + assert composite.primary_config_entry == entry_b.entry_id + assert composite.primary_config_entry != splits[0].config_entry_id + # It is a valid member of the merged config entries + assert composite.primary_config_entry in composite.config_entries + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_config_entry_clears_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Make sure we do not duplicate entries.""" - entry = 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={("bridgeid", "0123")}, - sw_version="sw-version", - name="name", - manufacturer="manufacturer", - model="model", - suggested_area="Game Room", - ) + """Clearing the composite's former primary config entry clears the dangling ref.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) - game_room_area = area_registry.async_get_area_by_name("Game Room") - assert game_room_area is not None - assert len(area_registry.areas) == 1 + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) - assert len(device_registry.devices) == 1 - assert entry.area_id == game_room_area.id - assert entry.suggested_area == "Game Room" + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b.composite_primary_config_entry == entry_a.entry_id + + # Clearing entry_a removes its split and clears the reference on entry_b's split + device_registry.async_clear_config_entry(entry_a.entry_id) assert ( - "The deprecated function suggested_area was called. It will be removed in " - "HA Core 2026.9. Use code which ignores suggested_area instead" - ) in caplog.text + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + is None + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b is not None + assert split_b.composite_primary_config_entry is None - device_registry.async_update_device(entry.id, suggested_area="TV Room") + # The restored composite still works, falling back to the remaining split + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.primary_config_entry == entry_b.entry_id - assert ( - "Detected code that passes a suggested_area to device_registry.async_update " - "device. This will stop working in Home Assistant 2026.9.0, please report " - "this issue" - ) in caplog.text + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_non_primary_config_entry_keeps_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing a non-primary config entry leaves composite_primary_config_entry intact.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + # Clearing entry_b (not the former primary) removes its split but keeps the reference + device_registry.async_clear_config_entry(entry_b.entry_id) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a is not None + assert split_a.composite_primary_config_entry == entry_a.entry_id + + +async def test_dict_repr_dual_writes_deprecated_keys( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test dict_repr exposes both the new and the deprecated compatibility keys.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + repr_ = device.dict_repr + assert repr_["config_entry_id"] == entry.entry_id + assert repr_["config_subentry_id"] is None + assert repr_["config_entries"] == [entry.entry_id] + assert repr_["config_entries_subentries"] == {entry.entry_id: [None]} + assert repr_["primary_config_entry"] == entry.entry_id + # Internal split-migration fields are not exposed in dict_repr + assert "composite_device_id" not in repr_ + assert "composite_primary_config_entry" not in repr_ + assert "split_at" not in repr_ + assert "has_composite_identifiers" not in repr_ diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a24f7f4b994aa8..532a594d33b49b 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -554,6 +554,39 @@ async def delayed_load(self: dr.DeviceRegistryStore) -> Any: assert registry.async_get("test.my_entity") is not None +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_load_detaches_from_dropped_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity referencing a device that no longer exists is detached on load. + + The device migration drops a device with no config entry; an entity that pointed at + it must be detached rather than left on a removed device id. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "gone-device", + "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.my_entity") + assert entity is not None + assert entity.device_id is None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1813,6 +1846,12 @@ async def test_migration_1_21( "area_id": None, "config_entries": ["mock_entry"], "config_entries_subentries": {"mock_entry": [None]}, + "config_entry_id": "mock_entry", + "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": "1970-01-01T00:00:00+00:00", @@ -2777,66 +2816,59 @@ async def test_remove_config_entry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config entry is removed.""" + """Test that we remove entities tied to a device when its config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + # Same connections on different config entries are separate devices + device_entry_1 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } + assert device_entry_1.id != device_entry_2.id - # Create one entity for each config entry + # Create one entity for each device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry_1, - device_id=device_entry.id, + device_id=device_entry_1.id, ) - entry_2 = entity_registry.async_get_or_create( "sensor", "device_tracker", "6789", config_entry=config_entry_2, - device_id=device_entry.id, + device_id=device_entry_2.id, ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device, the entity associated with it - # should be removed + # Removing the first config entry removes its device and the tied entity device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_1.entry_id + device_entry_1.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) + assert not device_registry.async_get(device_entry_1.id) assert not entity_registry.async_is_registered(entry_1.entity_id) + assert device_registry.async_get(device_entry_2.id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device, the entity associated with it - # (and the device itself) should be removed + # Removing the second config entry removes its device and entity too device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry_2.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry_2.id) assert not entity_registry.async_is_registered(entry_2.entity_id) @@ -2845,72 +2877,148 @@ async def test_remove_config_entry_from_device_removes_entities_2( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config entry.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2 = MockConfigEntry(domain="some_helper") config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry(domain="some_helper") - config_entry_3.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } - # Create an entity without config entry + # An entity without a config entry, tied to the device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity with a config entry not in the device + # An entity with a different config entry, tied to the device entry_2 = entity_registry.async_get_or_create( "light", "some_helper", "5678", - config_entry=config_entry_3, + config_entry=config_entry_2, device_id=device_entry.id, ) - assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device + # Removing the device's config entry removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - # Entities which are not tied to the removed config entry should not be removed + assert not device_registry.async_get(device_entry.id) + # Entities not tied to the removed config entry are kept, but detached assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None + + +async def test_move_device_config_entry_removes_old_entry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Moving a device to another config entry removes the old entry's entities.""" + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="tado") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="some_helper") + entry_c.add_to_hass(hass) - # Remove the second config entry from the device (this removes the device) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("hue", "1")} + ) + # An entity owned by the departing entry A, and a helper entity of a third entry C + entry_a_entity = entity_registry.async_get_or_create( + "light", "hue", "a", config_entry=entry_a, device_id=device_entry.id + ) + entry_c_entity = entity_registry.async_get_or_create( + "sensor", "some_helper", "c", config_entry=entry_c, device_id=device_entry.id + ) + + # Move the device from entry A to entry B (an update, not a removal) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry.id, new_config_entry_id=entry_b.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - # Entities which are not tied to a config entry in the device should not be removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - # Check the device link is set to None - assert entity_registry.async_get(entry_1.entity_id).device_id is None - assert entity_registry.async_get(entry_2.entity_id).device_id is None + # A no longer owns the device, so A's entity is removed; C's helper is untouched + assert not entity_registry.async_is_registered(entry_a_entity.entity_id) + assert entity_registry.async_is_registered(entry_c_entity.entity_id) + + +@pytest.mark.parametrize("old_subentry_id", [None, "sub-1"]) +async def test_move_device_config_subentry_removes_old_subentry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + old_subentry_id: str | None, +) -> None: + """Moving a device to another subentry removes the old subentry's entities. + + Includes a departing subentry of None (the main entry): the change is detected by the + old config_subentry_id being present in the event, not by its truthiness. + """ + config_entry = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=old_subentry_id, + identifiers={("hue", "1")}, + ) + # Entity on the departing subentry, and one on the destination subentry sub-2 + old_entity = entity_registry.async_get_or_create( + "light", + "hue", + "old", + config_entry=config_entry, + config_subentry_id=old_subentry_id, + device_id=device_entry.id, + ) + sub2_entity = entity_registry.async_get_or_create( + "light", + "hue", + "2", + config_entry=config_entry, + config_subentry_id="sub-2", + device_id=device_entry.id, + ) + + # Move the device to subentry sub-2 (an update, not a removal) + device_registry.async_update_device(device_entry.id, new_config_subentry_id="sub-2") + await hass.async_block_till_done() + + # The departing subentry's entity is removed; sub-2's entity is kept + assert not entity_registry.async_is_registered(old_entity.entity_id) + assert entity_registry.async_is_registered(sub2_entity.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -2918,7 +3026,7 @@ async def test_remove_config_subentry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config subentry is removed.""" + """Test that we remove entities tied to a device when its config subentry is removed.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -2940,27 +3048,15 @@ async def test_remove_config_subentry_from_device_removes_entities( ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) + # A device belongs to a single config subentry device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, - } + assert device_entry.config_subentry_id == "mock-subentry-id-1" - # Create one entity entry for each config entry or subentry + # Entity tied to the device's subentry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -2969,7 +3065,7 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-1", device_id=device_entry.id, ) - + # Entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", "hue", @@ -2978,22 +3074,11 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - entry_3 = entity_registry.async_get_or_create( - "sensor", - "device_tracker", - "6789", - config_entry=config_entry_1, - config_subentry_id=None, - device_id=device_entry.id, - ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device, the entity associated with it - # should be removed + # Removing the device's config subentry deletes the device; the entity tied to that + # subentry is removed, the entity tied to another subentry is detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -3001,55 +3086,18 @@ async def test_remove_config_subentry_from_device_removes_entities( ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, the entity associated with it - # should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the third config subentry from the device, the entity associated with it - # (and the device itself) should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-2", - ) - await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_2.entity_id).device_id is None -@pytest.mark.parametrize( - ("subentries_in_device", "subentry_in_entity"), - [ - (["mock-subentry-id-1", "mock-subentry-id-2"], None), - ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), - ], -) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - subentries_in_device: list[str | None], - subentry_in_entity: str | None, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config subentry.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -3067,95 +3115,49 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-3", - subentry_type="test", - title="Mock title", - unique_id="test", - ), ], ) config_entry_1.add_to_hass(hass) - # Create device with two config subentries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[0], - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[1], + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: set(subentries_in_device), - } - # Create an entity without config entry or subentry + # An entity without a config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity for same config entry but subentry not in device + # An entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", - "some_helper", - "5678", - config_entry=config_entry_1, - config_subentry_id=subentry_in_entity, - device_id=device_entry.id, - ) - # Create an entity for same config entry but subentry not in device - entry_3 = entity_registry.async_get_or_create( - "light", - "some_helper", + "hue", "abcd", config_entry=config_entry_1, - config_subentry_id="mock-subentry-id-3", + config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the first config subentry from the device - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[0], - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the second config subentry from the device, this removes the device + # Removing the device's config subentry deletes the device; entities not tied to + # that subentry are kept but detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[1], + remove_config_subentry_id="mock-subentry-id-1", ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Check the device link is set to None assert entity_registry.async_get(entry_1.entity_id).device_id is None assert entity_registry.async_get(entry_2.entity_id).device_id is None - assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( @@ -3642,9 +3644,9 @@ async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None: er.async_validate_entity_ids(entity_registry, ["unknown_uuid"]) -def test_entity_registry_items() -> None: +async def test_entity_registry_items(hass: HomeAssistant) -> None: """Test the EntityRegistryItems container.""" - entities = er.EntityRegistryItems() + entities = er.EntityRegistryItems(hass) assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None @@ -5406,3 +5408,293 @@ async def test_subentry( config_subentry_id="mock-subentry-id-2-1", ) assert entry.config_subentry_id == "mock-subentry-id-2-1" + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Entities are moved to the split device matching their config entry.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + assert entity_registry.async_get("sensor.a").device_id == by_entry[entry_a.entry_id] + assert entity_registry.async_get("sensor.b").device_id == by_entry[entry_b.entry_id] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities_fallbacks( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity not exactly matching a split falls back by config entry, then first split.""" + entry_a = MockConfigEntry( + domain="domain_a", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-sub", + subentry_type="test", + title="t", + unique_id="u", + ) + ], + ) + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The split for entry_a is on the "mock-sub" subentry + device_store = _composite_device_storage(entry_a, entry_b) + device_store["data"]["devices"][0]["config_entries_subentries"] = { + entry_a.entry_id: ["mock-sub"], + entry_b.entry_id: [None], + } + hass_storage[dr.STORAGE_KEY] = device_store + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + # config entry matches a split, but the subentry does not + "entity_id": "sensor.sub", + "platform": "domain_a", + "unique_id": "sub", + "config_entry_id": entry_a.entry_id, + "config_subentry_id": None, + "device_id": COMPOSITE_ID, + }, + { + # no split matches the config entry (it has none) + "entity_id": "sensor.none", + "platform": "domain_a", + "unique_id": "none", + "config_entry_id": None, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + by_entry = {d.config_entry_id: d.id for d in splits} + # Subentry mismatch falls back to the split owning the entity's config entry + assert ( + entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id] + ) + # No matching config entry falls back to the first split + assert entity_registry.async_get("sensor.none").device_id in {d.id for d in splits} + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_entries_for_device_legacy_composite_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A legacy composite device id resolves to its split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + # The composite id is no longer a live device; its entities were repointed to splits + assert COMPOSITE_ID not in device_registry.devices + + # get_entries_for_device_id resolves the composite id to the split entities + assert { + entry.entity_id + for entry in entity_registry.entities.get_entries_for_device_id(COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # The public helper resolves the composite id via the device registry + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # Disabled entities are only included when requested, across the split devices + entity_registry.async_update_entity( + "sensor.b", disabled_by=er.RegistryEntryDisabler.USER + ) + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a"} + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, COMPOSITE_ID, include_disabled_entities=True + ) + } == {"sensor.a", "sensor.b"} + + # A live split device id returns just its own entity + splits = { + device.config_entry_id: device.id + for device in device_registry.async_get_devices_for_composite_device_id( + COMPOSITE_ID + ) + } + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, splits[entry_a.entry_id] + ) + } == {"sensor.a"} + + +async def test_async_entries_for_device_composite_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A pre-migration composite id resolves to the underlying devices' entities. + + Backwards compatibility for unmodified integrations: before the single-config-entry + rewrite a shared identifier resolved to one multi-config-entry device, so + async_entries_for_device(composite_id) returned all of that device's entities. After + the split, the composite's virtual id must resolve to the same union so a legacy + reference keeps working. + """ + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + entity_1 = entity_registry.async_get_or_create( + "sensor", "itg1", "u1", config_entry=entry_1, device_id=device_1.id + ) + entity_2 = entity_registry.async_get_or_create( + "sensor", "itg2", "u2", config_entry=entry_2, device_id=device_2.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + assert old_id not in device_registry.devices + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, old_id) + } == {entity_1.entity_id, entity_2.entity_id} diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 640b2ff011af16..7b6d713419cedd 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -230,33 +230,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( set_source_entity_id_or_uuid: Mock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -267,10 +251,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -294,34 +274,18 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: - """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source_entity_removed handler is called when the source entity is removed.""" assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -331,9 +295,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the custom handler took over: the helper entity is left linked to the + # source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -357,21 +322,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity removed from the source device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Remove the source entity from the device @@ -381,9 +338,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -408,11 +365,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity is moved to another device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Create another device to move the source entity to source_device_2 = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, @@ -422,15 +374,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id not in source_device_2.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Move the source entity to another device @@ -442,11 +389,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is moved to the other device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id in source_device_2.config_entries + # Check that the helper entity is relinked to the other device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device_2.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -475,21 +420,13 @@ async def test_async_handle_source_entity_new_entity_id( set_source_entity_id_calls: int, ) -> None: """Test the source entity's entity ID is changed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Change the source entity's entity ID @@ -501,9 +438,9 @@ async def test_async_handle_source_entity_new_entity_id( assert len(async_unload_entry.mock_calls) == unload_calls assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls - # Check that the helper config is still in the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the helper entity is still linked to the source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -520,13 +457,25 @@ async def test_async_remove_helper_config_entry_from_source_device( entity_registry: er.EntityRegistry, helper_config_entry: MockConfigEntry, helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, source_device: dr.DeviceEntry, ) -> None: """Test removing the helper config entry from the source device.""" - # Add the helper config entry to the source device + # In the single-owner model the migration helper only acts when the helper config + # entry owns the source device. Move the source device to the helper config entry + # and record a pending move back to the source config entry, so removing the helper + # config entry hands the device back to the source config entry instead of deleting + # it. device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id + source_device.id, + add_config_entry_id=helper_config_entry.entry_id, + remove_config_entry_id=source_config_entry.entry_id, ) + device_registry.async_update_device( + source_device.id, add_config_entry_id=source_config_entry.entry_id + ) + source_device = device_registry.async_get(source_device.id) + assert source_device.config_entries == {helper_config_entry.entry_id} # Create a helper entity entry, not connected to the source device extra_helper_entity_entry = entity_registry.async_get_or_create( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 29c31d49477727..e9e459a4d60aa2 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -163,10 +163,18 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) mock_device_registry( hass, @@ -330,13 +338,21 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - device_has_label1 = dr.DeviceEntry(labels={"label1"}) - device_has_label2 = dr.DeviceEntry(labels={"label2"}) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label1"} + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label2"} + ) device_has_labels = dr.DeviceEntry( - labels={"label1", "label2"}, area_id=area_with_labels.id + config_entry_id="mock-config-entry", + labels={"label1", "label2"}, + area_id=area_with_labels.id, ) device_no_labels = dr.DeviceEntry( - id="device-no-labels", area_id=area_without_labels.id + config_entry_id="mock-config-entry", + id="device-no-labels", + area_id=area_without_labels.id, ) mock_device_registry( @@ -2491,7 +2507,10 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) + device_no_entities = dr.DeviceEntry( + config_entry_id="abc", + id="device-no-entities", + ) call = ServiceCall( hass, diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 9d72951868ed11..93b9adf7a67d60 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Mapping +from typing import Any import pytest @@ -109,13 +110,30 @@ def registries_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") - device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) - device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-test-area", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-diff-area", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label1-id", + labels={"label1"}, + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label2-id", + labels={"label2"}, + ) device_has_labels = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-has-labels-id", labels={"label1", "label2"}, area_id=area_with_labels.id, @@ -988,3 +1006,94 @@ def state_change_callback(event: target.TargetStateChangedData) -> None: assert len(events) == 1 unsub() + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_target_trickle_down_to_splits( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Targeting the legacy id reaches the split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": COMPOSITE_ID}) + ) + assert COMPOSITE_ID not in selected.missing_devices + splits = { + d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + # The composite id resolves to its splits only; it is not itself referenced (it is not + # a real device), so a device-id consumer does not act on the same device twice. + assert selected.referenced_devices == splits + assert COMPOSITE_ID not in selected.referenced_devices + assert selected.indirectly_referenced == {"sensor.a", "sensor.b"} diff --git a/tests/syrupy.py b/tests/syrupy.py index 253ebea3f247e4..09d5ea353f9cec 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -36,6 +36,17 @@ def __repr__(self) -> str: __all__ = ["HomeAssistantSnapshotExtension"] +# DeviceEntry attributes that are internal bookkeeping and should not appear in snapshots. +# Underscore attributes (_cache, _suggested_area and the transient _pending_move / +# _composite_subentries) are excluded separately. The composite-device migration +# attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + class AreaRegistryEntrySnapshot(dict): """Tiny wrapper to represent an area registry entry in snapshots.""" @@ -150,21 +161,31 @@ def _serializable_device_registry_entry( cls, data: dr.DeviceEntry ) -> SerializableData: """Prepare a Home Assistant device registry entry for serialization.""" + # Exclude internal attributes (caches, transient move state, and the + # composite-device migration bookkeeping) from the snapshot serialized = DeviceRegistryEntrySnapshot( - attrs.asdict(data) - | { - "config_entries": ANY, - "config_entries_subentries": ANY, - "id": ANY, - } + attr.asdict( + data, + retain_collection_types=True, + filter=lambda attribute, _: ( + not attribute.name.startswith("_") + and attribute.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ), + ) + | {"id": ANY} ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - if serialized["primary_config_entry"] is not None: - serialized["primary_config_entry"] = ANY - serialized.pop("_cache") - # This can be removed when suggested_area is removed from DeviceEntry - serialized.pop("_suggested_area") + + # Remove single config entry and subentry ids to not break snapshots + serialized.pop("config_entry_id") + serialized.pop("config_subentry_id") + + # Set removed composite device attributes to ANY to not break snapshots + serialized["config_entries"] = ANY + serialized["config_entries_subentries"] = ANY + serialized["primary_config_entry"] = ANY + return cls._remove_created_and_modified_at(serialized) @classmethod diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 62ebc4de916ff1..d2b672d500be3a 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6260,6 +6260,57 @@ async def test_loading_old_data( assert entry.pref_disable_new_entities is True +async def test_async_initialize_sets_event_with_empty_store( + hass: HomeAssistant, +) -> None: + """The initialized event is set when there is no stored data to load. + + The device registry waits on this event during its own load. + """ + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + with patch.object(manager._store, "async_load", return_value=None): + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert manager.async_entries() == [] + + +async def test_async_initialize_sets_event_with_existing_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The initialized event is set when loading an existing store. + + The device registry waits on this event during its own load. + """ + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "data": { + "entries": [ + { + "version": 5, + "domain": "my_domain", + "entry_id": "mock-id", + "data": {"my": "data"}, + "source": "user", + "title": "Mock title", + "system_options": {"disable_new_entities": True}, + } + ] + }, + } + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert len(manager.async_entries()) == 1 + + async def test_deprecated_disabled_by_str_ctor() -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" with pytest.raises(