Refactor coordinator data update and exception handling in Xbox integration (#154848)

This commit is contained in:
Manu
2025-10-21 15:07:37 +02:00
committed by GitHub
parent b08eb3a201
commit 84d9fa3bd7
6 changed files with 141 additions and 114 deletions

View File

@@ -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,
),
)

View File

@@ -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,
)

View File

@@ -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]

View File

@@ -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,
),
)

View File

@@ -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"

View File

@@ -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