From a5d4d636bc47dfdee458c1a42272be25019d4562 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 19:01:45 +0200 Subject: [PATCH] handle device_id being changed --- homeassistant/components/esphome/entity.py | 39 +++---- tests/components/esphome/test_entity.py | 126 +++++++++++++++++++++ 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 99e9d6704d7..501c773ba39 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -33,12 +33,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ( - ESPHomeConfigEntry, - RuntimeEntryData, - build_device_unique_id, - build_unique_id, -) +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) @@ -85,30 +80,24 @@ def async_static_info_updated( # Entity has switched devices, need to migrate unique_id old_unique_id = build_device_unique_id(device_info.mac_address, old_info) - new_unique_id = build_device_unique_id(device_info.mac_address, info) entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id) - # If not found by exact match, search for entity with base unique_id - # This happens when the YAML config was modified and renamed the device_id + # If entity not found in registry, re-add it + # This happens when the device_id changed and the old device was deleted if entity_id is None: - base_unique_id = build_unique_id(device_info.mac_address, info) - # Search all entities for this config entry - for entry in ent_reg.entities.get_entries_for_config_entry_id( - entry_data.entry_id - ): - if entry.platform != DOMAIN or entry.domain != platform.domain: - continue - # Check if it's the exact base unique_id or starts with base_unique_id@ - if entry.unique_id == base_unique_id or ( - entry.unique_id and entry.unique_id.startswith(f"{base_unique_id}@") - ): - entity_id = entry.entity_id - break - - # Entity must exist in registry since we found it in current_infos - assert entity_id is not None + _LOGGER.info( + "Entity with old unique_id %s not found in registry after device_id " + "changed from %s to %s, re-adding entity", + old_unique_id, + old_info.device_id, + info.device_id, + ) + entity = entity_type(entry_data, platform.domain, info, state_type) + add_entities.append(entity) + continue updates: dict[str, Any] = {} + new_unique_id = build_device_unique_id(device_info.mac_address, info) # Update unique_id if it changed if old_unique_id != new_unique_id: diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index f56d06f91b8..8d597ffecb0 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -1471,3 +1471,129 @@ async def test_unique_id_migration_between_sub_devices( ) assert bedroom_device is not None assert entity_entry.device_id == bedroom_device.id + + +async def test_entity_device_id_rename_in_yaml( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entities are re-added as new when user renames device_id in YAML config.""" + # Initial setup: entity on sub-device with device_id 11111111 + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="old_device", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on sub-device + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + unique_id="unused", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify initial entity setup + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Wait for entity to be registered + await hass.async_block_till_done() + + # Get the entity from registry + entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor") + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Should have @11111111 suffix + assert "@11111111" in initial_unique_id + + # Simulate user renaming device_id in YAML config + # The device_id hash changes from 11111111 to 99999999 + # This is treated as a completely new device + renamed_sub_devices = [ + SubDeviceInfo(device_id=99999999, name="renamed_device", area_id=0), + ] + + # Get the config entry from hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Update device_id_to_name mapping + entry_data = entry.runtime_data + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name for sub_device in renamed_sub_devices + } + + # Create new DeviceInfo with renamed device + current_device_info = mock_client.device_info.return_value + device_info_dict = asdict(current_device_info) + device_info_dict["devices"] = renamed_sub_devices + new_device_info = DeviceInfo(**device_info_dict) + mock_client.device_info.return_value = new_device_info + + # Entity info now has the new device_id + new_entity_info = [ + BinarySensorInfo( + object_id="sensor", # Same object_id + key=1, # Same key + name="Sensor", + unique_id="unused", + device_id=99999999, # New device_id after rename + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + + # Trigger a reconnect to simulate the YAML config change + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The old entity should be gone (device was deleted) + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is None + + # A new entity should exist with a new entity_id based on the new device name + # This is a completely new entity, not a migrated one + state = hass.states.get("binary_sensor.renamed_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Get the new entity from registry + entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor") + assert entity_entry is not None + + # Unique ID should have the new device_id + base_unique_id = initial_unique_id.replace("@11111111", "") + expected_unique_id = f"{base_unique_id}@99999999" + assert entity_entry.unique_id == expected_unique_id + + # Entity should be associated with the new device + renamed_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_99999999")} + ) + assert renamed_device is not None + assert entity_entry.device_id == renamed_device.id