mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 11:05:59 +01:00
Refactor coordinator data update and exception handling in Xbox integration (#154848)
This commit is contained in:
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user