From 84d9fa3bd76beddfc3b76ec4560c8bad967d2a91 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:07:37 +0200 Subject: [PATCH] Refactor coordinator data update and exception handling in Xbox integration (#154848) --- .../components/xbox/binary_sensor.py | 45 ++++-- homeassistant/components/xbox/coordinator.py | 150 +++++++----------- homeassistant/components/xbox/entity.py | 4 +- homeassistant/components/xbox/sensor.py | 24 +-- homeassistant/components/xbox/strings.json | 2 +- tests/components/xbox/test_init.py | 30 ++++ 6 files changed, 141 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index e754b4d79aa..b3c1fc7ce63 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from functools import partial +from xbox.webapi.api.provider.people.models import Person from yarl import URL from homeassistant.components.binary_sensor import ( @@ -16,7 +17,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PresenceData, XboxConfigEntry, XboxUpdateCoordinator +from .coordinator import XboxConfigEntry, XboxUpdateCoordinator from .entity import XboxBaseEntity @@ -34,11 +35,11 @@ class XboxBinarySensor(StrEnum): class XboxBinarySensorEntityDescription(BinarySensorEntityDescription): """Xbox binary sensor description.""" - is_on_fn: Callable[[PresenceData], bool | None] - entity_picture_fn: Callable[[PresenceData], str | None] | None = None + is_on_fn: Callable[[Person], bool | None] + entity_picture_fn: Callable[[Person], str | None] | None = None -def profile_pic(data: PresenceData) -> str | None: +def profile_pic(person: Person) -> str | None: """Return the gamer pic.""" # Xbox sometimes returns a domain that uses a wrong certificate which @@ -47,7 +48,7 @@ def profile_pic(data: PresenceData) -> str | None: # to point to the correct image, with the correct domain and certificate. # We need to also remove the 'mode=Padding' query because with it, # it results in an error 400. - url = URL(data.display_pic) + url = URL(person.display_pic_raw) if url.host == "images-eds.xboxlive.com": url = url.with_host("images-eds-ssl.xboxlive.com").with_scheme("https") query = dict(url.query) @@ -55,35 +56,59 @@ def profile_pic(data: PresenceData) -> str | None: return str(url.with_query(query)) +def in_game(person: Person) -> bool: + """True if person is in a game.""" + + active_app = ( + next( + (presence for presence in person.presence_details if presence.is_primary), + None, + ) + if person.presence_details + else None + ) + return ( + active_app is not None and active_app.is_game and active_app.state == "Active" + ) + + SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = ( XboxBinarySensorEntityDescription( key=XboxBinarySensor.ONLINE, translation_key=XboxBinarySensor.ONLINE, - is_on_fn=lambda x: x.online, + is_on_fn=lambda x: x.presence_state == "Online", name=None, entity_picture_fn=profile_pic, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_PARTY, translation_key=XboxBinarySensor.IN_PARTY, - is_on_fn=lambda x: x.in_party, + is_on_fn=( + lambda x: bool(x.multiplayer_summary.in_party) + if x.multiplayer_summary + else None + ), entity_registry_enabled_default=False, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_GAME, translation_key=XboxBinarySensor.IN_GAME, - is_on_fn=lambda x: x.in_game, + is_on_fn=in_game, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_MULTIPLAYER, translation_key=XboxBinarySensor.IN_MULTIPLAYER, - is_on_fn=lambda x: x.in_multiplayer, + is_on_fn=( + lambda x: bool(x.multiplayer_summary.in_multiplayer_session) + if x.multiplayer_summary + else None + ), entity_registry_enabled_default=False, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.HAS_GAME_PASS, translation_key=XboxBinarySensor.HAS_GAME_PASS, - is_on_fn=lambda x: x.has_game_pass, + is_on_fn=lambda x: x.detail.has_game_pass if x.detail else None, ), ) diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 17040e65464..651e08f52a0 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -3,18 +3,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import timedelta import logging from httpx import HTTPStatusError, RequestError, TimeoutException from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product -from xbox.webapi.api.provider.people.models import ( - PeopleResponse, - Person, - PresenceDetail, -) +from xbox.webapi.api.provider.people.models import Person from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleList, SmartglassConsoleStatus, @@ -25,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import api from .const import DOMAIN @@ -43,33 +39,12 @@ class ConsoleData: app_details: Product | None -@dataclass -class PresenceData: - """Xbox user presence data.""" - - xuid: str - gamertag: str - display_pic: str - online: bool - status: str - in_party: bool - in_game: bool - in_multiplayer: bool - gamer_score: str - gold_tenure: str | None - account_tier: str - last_seen: datetime | None - following_count: int - follower_count: int - has_game_pass: bool - - @dataclass class XboxData: """Xbox dataclass for update coordinator.""" consoles: dict[str, ConsoleData] = field(default_factory=dict) - presence: dict[str, PresenceData] = field(default_factory=dict) + presence: dict[str, Person] = field(default_factory=dict) class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): @@ -107,7 +82,6 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_exception", - translation_placeholders={"error": str(e)}, ) from e session = config_entry_oauth2_flow.OAuth2Session( @@ -129,7 +103,6 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_exception", - translation_placeholders={"error": str(e)}, ) from e _LOGGER.debug( @@ -143,11 +116,20 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): # Update Console Status new_console_data: dict[str, ConsoleData] = {} for console in self.consoles.result: - current_state: ConsoleData | None = self.data.consoles.get(console.id) - status: SmartglassConsoleStatus = ( - await self.client.smartglass.get_console_status(console.id) - ) - + current_state = self.data.consoles.get(console.id) + try: + status = await self.client.smartglass.get_console_status(console.id) + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e _LOGGER.debug( "%s status: %s", console.name, @@ -169,13 +151,26 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): if app_id in SYSTEM_PFN_ID_MAP: id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID app_id = SYSTEM_PFN_ID_MAP[app_id][id_type] - catalog_result = ( - await self.client.catalog.get_product_from_alternate_id( - app_id, id_type + try: + catalog_result = ( + await self.client.catalog.get_product_from_alternate_id( + app_id, id_type + ) ) - ) - if catalog_result and catalog_result.products: - app_details = catalog_result.products[0] + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + else: + if catalog_result.products: + app_details = catalog_result.products[0] else: app_details = None @@ -184,19 +179,25 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): ) # Update user presence - presence_data: dict[str, PresenceData] = {} - batch: PeopleResponse = await self.client.people.get_friends_own_batch( - [self.client.xuid] - ) - own_presence: Person = batch.people[0] - presence_data[own_presence.xuid] = _build_presence_data(own_presence) - - friends: PeopleResponse = await self.client.people.get_friends_own() - for friend in friends.people: - if not friend.is_favorite: - continue - - presence_data[friend.xuid] = _build_presence_data(friend) + try: + batch = await self.client.people.get_friends_own_batch([self.client.xuid]) + friends = await self.client.people.get_friends_own() + except TimeoutException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + else: + presence_data = {self.client.xuid: batch.people[0]} + presence_data.update( + {friend.xuid: friend for friend in friends.people if friend.is_favorite} + ) if ( self.current_friends @@ -208,11 +209,11 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): return XboxData(new_console_data, presence_data) - def remove_stale_devices(self, presence_data: dict[str, PresenceData]) -> None: + def remove_stale_devices(self, presence_data: dict[str, Person]) -> None: """Remove stale devices from registry.""" device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, person.xuid) for person in presence_data.values()} | { + identifiers = {(DOMAIN, xuid) for xuid in set(presence_data)} | { (DOMAIN, console.id) for console in self.consoles.result } @@ -224,38 +225,3 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): device_reg.async_update_device( device.id, remove_config_entry_id=self.config_entry.entry_id ) - - -def _build_presence_data(person: Person) -> PresenceData: - """Build presence data from a person.""" - active_app: PresenceDetail | None = None - - active_app = next( - (presence for presence in person.presence_details if presence.is_primary), - None, - ) - in_game = ( - active_app is not None and active_app.is_game and active_app.state == "Active" - ) - - return PresenceData( - xuid=person.xuid, - gamertag=person.gamertag, - display_pic=person.display_pic_raw, - online=person.presence_state == "Online", - status=person.presence_text, - in_party=person.multiplayer_summary.in_party > 0, - in_game=in_game, - in_multiplayer=person.multiplayer_summary.in_multiplayer_session, - gamer_score=person.gamer_score, - gold_tenure=person.detail.tenure, - account_tier=person.detail.account_tier, - last_seen=( - person.last_seen_date_time_utc.replace(tzinfo=UTC) - if person.last_seen_date_time_utc - else None - ), - follower_count=person.detail.follower_count, - following_count=person.detail.following_count, - has_game_pass=person.detail.has_game_pass, - ) diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 40917da792f..410ef7306ed 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PresenceData, XboxUpdateCoordinator +from .coordinator import Person, XboxUpdateCoordinator class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): @@ -37,6 +37,6 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): ) @property - def data(self) -> PresenceData: + def data(self) -> Person: """Return coordinator data for this console.""" return self.coordinator.data.presence[self.xuid] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 3b064797532..d2fd639645e 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -4,10 +4,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from enum import StrEnum from functools import partial +from xbox.webapi.api.provider.people.models import Person + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -17,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import PresenceData, XboxConfigEntry, XboxUpdateCoordinator +from .coordinator import XboxConfigEntry, XboxUpdateCoordinator from .entity import XboxBaseEntity @@ -37,14 +39,14 @@ class XboxSensor(StrEnum): class XboxSensorEntityDescription(SensorEntityDescription): """Xbox sensor description.""" - value_fn: Callable[[PresenceData], StateType | datetime] + value_fn: Callable[[Person], StateType | datetime] SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = ( XboxSensorEntityDescription( key=XboxSensor.STATUS, translation_key=XboxSensor.STATUS, - value_fn=lambda x: x.status, + value_fn=lambda x: x.presence_text, ), XboxSensorEntityDescription( key=XboxSensor.GAMER_SCORE, @@ -55,29 +57,33 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = ( key=XboxSensor.ACCOUNT_TIER, translation_key=XboxSensor.ACCOUNT_TIER, entity_registry_enabled_default=False, - value_fn=lambda x: x.account_tier, + value_fn=lambda x: x.detail.account_tier if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.GOLD_TENURE, translation_key=XboxSensor.GOLD_TENURE, entity_registry_enabled_default=False, - value_fn=lambda x: x.gold_tenure, + value_fn=lambda x: x.detail.tenure if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.LAST_ONLINE, translation_key=XboxSensor.LAST_ONLINE, - value_fn=(lambda x: x.last_seen), + value_fn=( + lambda x: x.last_seen_date_time_utc.replace(tzinfo=UTC) + if x.last_seen_date_time_utc + else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), XboxSensorEntityDescription( key=XboxSensor.FOLLOWING, translation_key=XboxSensor.FOLLOWING, - value_fn=lambda x: x.following_count, + value_fn=lambda x: x.detail.following_count if x.detail else None, ), XboxSensorEntityDescription( key=XboxSensor.FOLLOWER, translation_key=XboxSensor.FOLLOWER, - value_fn=lambda x: x.follower_count, + value_fn=lambda x: x.detail.follower_count if x.detail else None, ), ) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 2df1546f22c..5780aec398d 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -68,7 +68,7 @@ }, "exceptions": { "request_exception": { - "message": "Failed to connect to Xbox Network: {error}" + "message": "Failed to connect to Xbox Network" }, "timeout_exception": { "message": "Failed to connect to Xbox Network due to a connection timeout" diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 3a787476386..cda608d5ac5 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -64,3 +64,33 @@ async def test_config_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("exception", [ConnectTimeout, HTTPStatusError, ProtocolError]) +@pytest.mark.parametrize( + ("provider", "method"), + [ + ("smartglass", "get_console_status"), + ("catalog", "get_product_from_alternate_id"), + ("people", "get_friends_own_batch"), + ("people", "get_friends_own"), + ], +) +async def test_coordinator_update_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + xbox_live_client: AsyncMock, + exception: Exception, + provider: str, + method: str, +) -> None: + """Test coordinator update failed.""" + + provider = getattr(xbox_live_client, provider) + getattr(provider, method).side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY