Skip to content

Commit 84d9fa3

Browse files
authored
Refactor coordinator data update and exception handling in Xbox integration (home-assistant#154848)
1 parent b08eb3a commit 84d9fa3

File tree

6 files changed

+141
-114
lines changed

6 files changed

+141
-114
lines changed

homeassistant/components/xbox/binary_sensor.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from enum import StrEnum
88
from functools import partial
99

10+
from xbox.webapi.api.provider.people.models import Person
1011
from yarl import URL
1112

1213
from homeassistant.components.binary_sensor import (
@@ -16,7 +17,7 @@
1617
from homeassistant.core import HomeAssistant, callback
1718
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1819

19-
from .coordinator import PresenceData, XboxConfigEntry, XboxUpdateCoordinator
20+
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
2021
from .entity import XboxBaseEntity
2122

2223

@@ -34,11 +35,11 @@ class XboxBinarySensor(StrEnum):
3435
class XboxBinarySensorEntityDescription(BinarySensorEntityDescription):
3536
"""Xbox binary sensor description."""
3637

37-
is_on_fn: Callable[[PresenceData], bool | None]
38-
entity_picture_fn: Callable[[PresenceData], str | None] | None = None
38+
is_on_fn: Callable[[Person], bool | None]
39+
entity_picture_fn: Callable[[Person], str | None] | None = None
3940

4041

41-
def profile_pic(data: PresenceData) -> str | None:
42+
def profile_pic(person: Person) -> str | None:
4243
"""Return the gamer pic."""
4344

4445
# Xbox sometimes returns a domain that uses a wrong certificate which
@@ -47,43 +48,67 @@ def profile_pic(data: PresenceData) -> str | None:
4748
# to point to the correct image, with the correct domain and certificate.
4849
# We need to also remove the 'mode=Padding' query because with it,
4950
# it results in an error 400.
50-
url = URL(data.display_pic)
51+
url = URL(person.display_pic_raw)
5152
if url.host == "images-eds.xboxlive.com":
5253
url = url.with_host("images-eds-ssl.xboxlive.com").with_scheme("https")
5354
query = dict(url.query)
5455
query.pop("mode", None)
5556
return str(url.with_query(query))
5657

5758

59+
def in_game(person: Person) -> bool:
60+
"""True if person is in a game."""
61+
62+
active_app = (
63+
next(
64+
(presence for presence in person.presence_details if presence.is_primary),
65+
None,
66+
)
67+
if person.presence_details
68+
else None
69+
)
70+
return (
71+
active_app is not None and active_app.is_game and active_app.state == "Active"
72+
)
73+
74+
5875
SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
5976
XboxBinarySensorEntityDescription(
6077
key=XboxBinarySensor.ONLINE,
6178
translation_key=XboxBinarySensor.ONLINE,
62-
is_on_fn=lambda x: x.online,
79+
is_on_fn=lambda x: x.presence_state == "Online",
6380
name=None,
6481
entity_picture_fn=profile_pic,
6582
),
6683
XboxBinarySensorEntityDescription(
6784
key=XboxBinarySensor.IN_PARTY,
6885
translation_key=XboxBinarySensor.IN_PARTY,
69-
is_on_fn=lambda x: x.in_party,
86+
is_on_fn=(
87+
lambda x: bool(x.multiplayer_summary.in_party)
88+
if x.multiplayer_summary
89+
else None
90+
),
7091
entity_registry_enabled_default=False,
7192
),
7293
XboxBinarySensorEntityDescription(
7394
key=XboxBinarySensor.IN_GAME,
7495
translation_key=XboxBinarySensor.IN_GAME,
75-
is_on_fn=lambda x: x.in_game,
96+
is_on_fn=in_game,
7697
),
7798
XboxBinarySensorEntityDescription(
7899
key=XboxBinarySensor.IN_MULTIPLAYER,
79100
translation_key=XboxBinarySensor.IN_MULTIPLAYER,
80-
is_on_fn=lambda x: x.in_multiplayer,
101+
is_on_fn=(
102+
lambda x: bool(x.multiplayer_summary.in_multiplayer_session)
103+
if x.multiplayer_summary
104+
else None
105+
),
81106
entity_registry_enabled_default=False,
82107
),
83108
XboxBinarySensorEntityDescription(
84109
key=XboxBinarySensor.HAS_GAME_PASS,
85110
translation_key=XboxBinarySensor.HAS_GAME_PASS,
86-
is_on_fn=lambda x: x.has_game_pass,
111+
is_on_fn=lambda x: x.detail.has_game_pass if x.detail else None,
87112
),
88113
)
89114

homeassistant/components/xbox/coordinator.py

Lines changed: 58 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,14 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6-
from datetime import UTC, datetime, timedelta
6+
from datetime import timedelta
77
import logging
88

99
from httpx import HTTPStatusError, RequestError, TimeoutException
1010
from xbox.webapi.api.client import XboxLiveClient
1111
from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP
1212
from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product
13-
from xbox.webapi.api.provider.people.models import (
14-
PeopleResponse,
15-
Person,
16-
PresenceDetail,
17-
)
13+
from xbox.webapi.api.provider.people.models import Person
1814
from xbox.webapi.api.provider.smartglass.models import (
1915
SmartglassConsoleList,
2016
SmartglassConsoleStatus,
@@ -25,7 +21,7 @@
2521
from homeassistant.core import HomeAssistant
2622
from homeassistant.exceptions import ConfigEntryNotReady
2723
from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr
28-
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
24+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
2925

3026
from . import api
3127
from .const import DOMAIN
@@ -43,33 +39,12 @@ class ConsoleData:
4339
app_details: Product | None
4440

4541

46-
@dataclass
47-
class PresenceData:
48-
"""Xbox user presence data."""
49-
50-
xuid: str
51-
gamertag: str
52-
display_pic: str
53-
online: bool
54-
status: str
55-
in_party: bool
56-
in_game: bool
57-
in_multiplayer: bool
58-
gamer_score: str
59-
gold_tenure: str | None
60-
account_tier: str
61-
last_seen: datetime | None
62-
following_count: int
63-
follower_count: int
64-
has_game_pass: bool
65-
66-
6742
@dataclass
6843
class XboxData:
6944
"""Xbox dataclass for update coordinator."""
7045

7146
consoles: dict[str, ConsoleData] = field(default_factory=dict)
72-
presence: dict[str, PresenceData] = field(default_factory=dict)
47+
presence: dict[str, Person] = field(default_factory=dict)
7348

7449

7550
class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
@@ -107,7 +82,6 @@ async def _async_setup(self) -> None:
10782
raise ConfigEntryNotReady(
10883
translation_domain=DOMAIN,
10984
translation_key="request_exception",
110-
translation_placeholders={"error": str(e)},
11185
) from e
11286

11387
session = config_entry_oauth2_flow.OAuth2Session(
@@ -129,7 +103,6 @@ async def _async_setup(self) -> None:
129103
raise ConfigEntryNotReady(
130104
translation_domain=DOMAIN,
131105
translation_key="request_exception",
132-
translation_placeholders={"error": str(e)},
133106
) from e
134107

135108
_LOGGER.debug(
@@ -143,11 +116,20 @@ async def _async_update_data(self) -> XboxData:
143116
# Update Console Status
144117
new_console_data: dict[str, ConsoleData] = {}
145118
for console in self.consoles.result:
146-
current_state: ConsoleData | None = self.data.consoles.get(console.id)
147-
status: SmartglassConsoleStatus = (
148-
await self.client.smartglass.get_console_status(console.id)
149-
)
150-
119+
current_state = self.data.consoles.get(console.id)
120+
try:
121+
status = await self.client.smartglass.get_console_status(console.id)
122+
except TimeoutException as e:
123+
raise UpdateFailed(
124+
translation_domain=DOMAIN,
125+
translation_key="timeout_exception",
126+
) from e
127+
except (RequestError, HTTPStatusError) as e:
128+
_LOGGER.debug("Xbox exception:", exc_info=True)
129+
raise UpdateFailed(
130+
translation_domain=DOMAIN,
131+
translation_key="request_exception",
132+
) from e
151133
_LOGGER.debug(
152134
"%s status: %s",
153135
console.name,
@@ -169,13 +151,26 @@ async def _async_update_data(self) -> XboxData:
169151
if app_id in SYSTEM_PFN_ID_MAP:
170152
id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID
171153
app_id = SYSTEM_PFN_ID_MAP[app_id][id_type]
172-
catalog_result = (
173-
await self.client.catalog.get_product_from_alternate_id(
174-
app_id, id_type
154+
try:
155+
catalog_result = (
156+
await self.client.catalog.get_product_from_alternate_id(
157+
app_id, id_type
158+
)
175159
)
176-
)
177-
if catalog_result and catalog_result.products:
178-
app_details = catalog_result.products[0]
160+
except TimeoutException as e:
161+
raise UpdateFailed(
162+
translation_domain=DOMAIN,
163+
translation_key="timeout_exception",
164+
) from e
165+
except (RequestError, HTTPStatusError) as e:
166+
_LOGGER.debug("Xbox exception:", exc_info=True)
167+
raise UpdateFailed(
168+
translation_domain=DOMAIN,
169+
translation_key="request_exception",
170+
) from e
171+
else:
172+
if catalog_result.products:
173+
app_details = catalog_result.products[0]
179174
else:
180175
app_details = None
181176

@@ -184,19 +179,25 @@ async def _async_update_data(self) -> XboxData:
184179
)
185180

186181
# Update user presence
187-
presence_data: dict[str, PresenceData] = {}
188-
batch: PeopleResponse = await self.client.people.get_friends_own_batch(
189-
[self.client.xuid]
190-
)
191-
own_presence: Person = batch.people[0]
192-
presence_data[own_presence.xuid] = _build_presence_data(own_presence)
193-
194-
friends: PeopleResponse = await self.client.people.get_friends_own()
195-
for friend in friends.people:
196-
if not friend.is_favorite:
197-
continue
198-
199-
presence_data[friend.xuid] = _build_presence_data(friend)
182+
try:
183+
batch = await self.client.people.get_friends_own_batch([self.client.xuid])
184+
friends = await self.client.people.get_friends_own()
185+
except TimeoutException as e:
186+
raise UpdateFailed(
187+
translation_domain=DOMAIN,
188+
translation_key="timeout_exception",
189+
) from e
190+
except (RequestError, HTTPStatusError) as e:
191+
_LOGGER.debug("Xbox exception:", exc_info=True)
192+
raise UpdateFailed(
193+
translation_domain=DOMAIN,
194+
translation_key="request_exception",
195+
) from e
196+
else:
197+
presence_data = {self.client.xuid: batch.people[0]}
198+
presence_data.update(
199+
{friend.xuid: friend for friend in friends.people if friend.is_favorite}
200+
)
200201

201202
if (
202203
self.current_friends
@@ -208,11 +209,11 @@ async def _async_update_data(self) -> XboxData:
208209

209210
return XboxData(new_console_data, presence_data)
210211

211-
def remove_stale_devices(self, presence_data: dict[str, PresenceData]) -> None:
212+
def remove_stale_devices(self, presence_data: dict[str, Person]) -> None:
212213
"""Remove stale devices from registry."""
213214

214215
device_reg = dr.async_get(self.hass)
215-
identifiers = {(DOMAIN, person.xuid) for person in presence_data.values()} | {
216+
identifiers = {(DOMAIN, xuid) for xuid in set(presence_data)} | {
216217
(DOMAIN, console.id) for console in self.consoles.result
217218
}
218219

@@ -224,38 +225,3 @@ def remove_stale_devices(self, presence_data: dict[str, PresenceData]) -> None:
224225
device_reg.async_update_device(
225226
device.id, remove_config_entry_id=self.config_entry.entry_id
226227
)
227-
228-
229-
def _build_presence_data(person: Person) -> PresenceData:
230-
"""Build presence data from a person."""
231-
active_app: PresenceDetail | None = None
232-
233-
active_app = next(
234-
(presence for presence in person.presence_details if presence.is_primary),
235-
None,
236-
)
237-
in_game = (
238-
active_app is not None and active_app.is_game and active_app.state == "Active"
239-
)
240-
241-
return PresenceData(
242-
xuid=person.xuid,
243-
gamertag=person.gamertag,
244-
display_pic=person.display_pic_raw,
245-
online=person.presence_state == "Online",
246-
status=person.presence_text,
247-
in_party=person.multiplayer_summary.in_party > 0,
248-
in_game=in_game,
249-
in_multiplayer=person.multiplayer_summary.in_multiplayer_session,
250-
gamer_score=person.gamer_score,
251-
gold_tenure=person.detail.tenure,
252-
account_tier=person.detail.account_tier,
253-
last_seen=(
254-
person.last_seen_date_time_utc.replace(tzinfo=UTC)
255-
if person.last_seen_date_time_utc
256-
else None
257-
),
258-
follower_count=person.detail.follower_count,
259-
following_count=person.detail.following_count,
260-
has_game_pass=person.detail.has_game_pass,
261-
)

homeassistant/components/xbox/entity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from homeassistant.helpers.update_coordinator import CoordinatorEntity
88

99
from .const import DOMAIN
10-
from .coordinator import PresenceData, XboxUpdateCoordinator
10+
from .coordinator import Person, XboxUpdateCoordinator
1111

1212

1313
class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
@@ -37,6 +37,6 @@ def __init__(
3737
)
3838

3939
@property
40-
def data(self) -> PresenceData:
40+
def data(self) -> Person:
4141
"""Return coordinator data for this console."""
4242
return self.coordinator.data.presence[self.xuid]

0 commit comments

Comments
 (0)