From 8f57e2b81be08d98fab1350c117c1dfcb422d67c Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 2 Sep 2026 11:51:01 +0200 Subject: [PATCH] Fix offline devices being removed in Xbox integration (#181045) --- homeassistant/components/xbox/__init__.py | 21 ++++++++++++ homeassistant/components/xbox/coordinator.py | 14 -------- tests/components/xbox/test_init.py | 36 ++++++++++++-------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 0112b68f13b3..277471c9c8b4 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -88,6 +88,27 @@ async def async_unload_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> boo return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: XboxConfigEntry, + device_entry: dr.AnyDeviceEntry, +) -> bool: + """Remove a stale device from a config entry.""" + + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ( + ( + isinstance(device_entry, dr.DeviceEntry) + and device_entry.entry_type == dr.DeviceEntryType.SERVICE + ) + or identifier[1] in config_entry.runtime_data.consoles.data + ) + ) + + async def async_migrate_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: """Migrate config entry.""" diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index d9b0766d70eb..bf6eb7f7360b 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -20,8 +20,6 @@ from pythonxbox.api.provider.titlehub.models import Title from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -117,18 +115,6 @@ class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]]) "Found %d consoles: %s", len(consoles.result), consoles.model_dump() ) - device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, console.id) for console in consoles.result} - for device in dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ): - if ( - device.entry_type is not DeviceEntryType.SERVICE - and not set(device.identifiers) & identifiers - ): - _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_remove_device(device.id) - return {console.id: console for console in consoles.result} diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 469a96b6f2b6..3b9ab66dd33e 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -23,12 +23,14 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, async_fire_time_changed, async_load_json_object_fixture, ) +from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("xbox_live_client") @@ -204,9 +206,11 @@ async def test_dynamic_devices( xbox_live_client: AsyncMock, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + hass_ws_client: WebSocketGenerator, ) -> None: """Test adding of new and removal of stale devices at runtime.""" - + assert await async_setup_component(hass, "config", {}) + client = await hass_ws_client(hass) xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( hass, "smartglass_console_list_empty.json", DOMAIN @@ -225,12 +229,6 @@ async def test_dynamic_devices( ) is None ) - assert ( - device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id - ) - is None - ) xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( @@ -242,13 +240,15 @@ async def test_dynamic_devices( async_fire_time_changed(hass) await hass.async_block_till_done() - assert device_registry.async_get_device_by_identifier( - (DOMAIN, "ABCDEFG"), config_entry.entry_id - ) - assert device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id + assert ( + device := device_registry.async_get_device_by_identifier( + (DOMAIN, "ABCDEFG"), config_entry.entry_id + ) ) + response = await client.remove_device(device.id) + assert not response["success"] + xbox_live_client.smartglass.get_console_list.return_value = SmartglassConsoleList( **await async_load_json_object_fixture( hass, "smartglass_console_list_empty.json", DOMAIN @@ -259,15 +259,21 @@ async def test_dynamic_devices( async_fire_time_changed(hass) await hass.async_block_till_done() + response = await client.remove_device(device.id) + assert response["success"] + assert ( device_registry.async_get_device_by_identifier( (DOMAIN, "ABCDEFG"), config_entry.entry_id ) is None ) + + # Test that service devices cannot be removed assert ( - device_registry.async_get_device_by_identifier( - (DOMAIN, "HIJKLMN"), config_entry.entry_id + account := device_registry.async_get_device_by_identifier( + (DOMAIN, "271958441785640"), config_entry.entry_id ) - is None ) + response = await client.remove_device(account.id) + assert not response["success"]