diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index c7f7d4c369d..463b5c4dddc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1460,12 +1460,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if config_entry_id not in config_entries: continue if config_entries == {config_entry_id}: + # Clear disabled_by if it was disabled by the config entry + if deleted_device.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + else: + disabled_by = deleted_device.disabled_by # 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={}, + disabled_by=disabled_by, ) else: config_entries = config_entries - {config_entry_id} diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d972b421fc4..2125c0f4512 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1613,9 +1613,17 @@ class EntityRegistry(BaseRegistry): for key, deleted_entity in list(self.deleted_entities.items()): if config_entry_id != deleted_entity.config_entry_id: continue + # Clear disabled_by if it was disabled by the config entry + if deleted_entity.disabled_by is RegistryEntryDisabler.CONFIG_ENTRY: + disabled_by = None + else: + disabled_by = deleted_entity.disabled_by # Add a time stamp when the deleted entity became orphaned self.deleted_entities[key] = attr.evolve( - deleted_entity, orphaned_timestamp=now_time, config_entry_id=None + deleted_entity, + orphaned_timestamp=now_time, + config_entry_id=None, + disabled_by=disabled_by, ) self.async_schedule_save() diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index d056c25fc3b..d45c4f6cf91 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -3368,6 +3368,98 @@ async def test_cleanup_startup(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 1 +async def test_deleted_device_clears_disabled_by_on_config_entry_removal( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that disabled_by is cleared when config entry is removed.""" + config_entry = MockConfigEntry(domain="test", entry_id="mock-id-1") + config_entry.add_to_hass(hass) + + # Create a device disabled by the config entry + device = device_registry.async_get_or_create( + config_entry_id="mock-id-1", + identifiers={("test", "device_1")}, + name="Test Device", + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + assert device.config_entries == {"mock-id-1"} + assert device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + # Remove the device (it moves to deleted_devices) + device_registry.async_remove_device(device.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + deleted_device = device_registry.deleted_devices[device.id] + assert deleted_device.config_entries == {"mock-id-1"} + assert deleted_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + assert deleted_device.orphaned_timestamp is None + + # Clear the config entry + device_registry.async_clear_config_entry("mock-id-1") + + # Verify disabled_by is cleared + deleted_device = device_registry.deleted_devices[device.id] + assert deleted_device.config_entries == set() + assert deleted_device.disabled_by is None # Should be cleared + assert deleted_device.orphaned_timestamp is not None + + # Now re-add the config entry and device to verify it can be enabled + config_entry2 = MockConfigEntry(domain="test", entry_id="mock-id-2") + config_entry2.add_to_hass(hass) + + # Re-create the device with same identifiers + device2 = device_registry.async_get_or_create( + config_entry_id="mock-id-2", + identifiers={("test", "device_1")}, + name="Test Device", + ) + assert device2.config_entries == {"mock-id-2"} + assert device2.disabled_by is None # Should not be disabled anymore + assert device2.id == device.id # Should keep the same device id + + +async def test_deleted_device_disabled_by_user_not_cleared( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that disabled_by=USER is not cleared when config entry is removed.""" + config_entry = MockConfigEntry(domain="test", entry_id="mock-id-1") + config_entry.add_to_hass(hass) + + # Create a device disabled by the user + device = device_registry.async_get_or_create( + config_entry_id="mock-id-1", + identifiers={("test", "device_1")}, + name="Test Device", + disabled_by=dr.DeviceEntryDisabler.USER, + ) + assert device.config_entries == {"mock-id-1"} + assert device.disabled_by is dr.DeviceEntryDisabler.USER + + # Remove the device (it moves to deleted_devices) + device_registry.async_remove_device(device.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + deleted_device = device_registry.deleted_devices[device.id] + assert deleted_device.config_entries == {"mock-id-1"} + assert deleted_device.disabled_by is dr.DeviceEntryDisabler.USER + assert deleted_device.orphaned_timestamp is None + + # Clear the config entry + device_registry.async_clear_config_entry("mock-id-1") + + # Verify disabled_by is NOT cleared for USER disabled devices + deleted_device = device_registry.deleted_devices[device.id] + assert deleted_device.config_entries == set() + assert ( + deleted_device.disabled_by is dr.DeviceEntryDisabler.USER + ) # Should remain USER + assert deleted_device.orphaned_timestamp is not None + + @pytest.mark.parametrize("load_registries", [False]) async def test_cleanup_entity_registry_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index e403333d8df..89822b80039 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -782,6 +782,97 @@ async def test_deleted_entity_removing_config_entry_id( assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 +async def test_deleted_entity_clears_disabled_by_on_config_entry_removal( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that disabled_by is cleared when config entry is removed.""" + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) + + # Create an entity disabled by the config entry + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY, + ) + assert entry.config_entry_id == "mock-id-1" + assert entry.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + + # Remove the entity (it moves to deleted_entities) + entity_registry.async_remove(entry.entity_id) + + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry.config_entry_id == "mock-id-1" + assert deleted_entry.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + assert deleted_entry.orphaned_timestamp is None + + # Clear the config entry + entity_registry.async_clear_config_entry("mock-id-1") + + # Verify disabled_by is cleared + deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry.config_entry_id is None + assert deleted_entry.disabled_by is None # Should be cleared + assert deleted_entry.orphaned_timestamp is not None + + # Now re-add the config entry and entity to verify it can be enabled + mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config2.add_to_hass(hass) + + # Re-create the entity with same unique ID + entry2 = entity_registry.async_get_or_create( + "light", "hue", "5678", config_entry=mock_config2 + ) + assert entry2.config_entry_id == "mock-id-2" + assert entry2.disabled_by is None # Should not be disabled anymore + + +async def test_deleted_entity_disabled_by_user_not_cleared( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that disabled_by=USER is not cleared when config entry is removed.""" + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) + + # Create an entity disabled by the user + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert entry.config_entry_id == "mock-id-1" + assert entry.disabled_by is er.RegistryEntryDisabler.USER + + # Remove the entity (it moves to deleted_entities) + entity_registry.async_remove(entry.entity_id) + + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry.config_entry_id == "mock-id-1" + assert deleted_entry.disabled_by is er.RegistryEntryDisabler.USER + assert deleted_entry.orphaned_timestamp is None + + # Clear the config entry + entity_registry.async_clear_config_entry("mock-id-1") + + # Verify disabled_by is NOT cleared for USER disabled entities + deleted_entry = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry.config_entry_id is None + assert ( + deleted_entry.disabled_by is er.RegistryEntryDisabler.USER + ) # Should remain USER + assert deleted_entry.orphaned_timestamp is not None + + async def test_removing_config_subentry_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: