From c10fe4f723d99a2f689dce8b7bafcf768806532f Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Thu, 15 Oct 2020 19:11:05 -0400 Subject: [PATCH] Add sensors to Xbox integration (#41868) * favorited friends binary sensors * add binary_sensor to .coveragerc * fix copy/paste comments... * make sensor entities instead of attributes * address PR review comments * default state to None --- .coveragerc | 3 + homeassistant/components/xbox/__init__.py | 94 +++++++++++++++++-- homeassistant/components/xbox/base_sensor.py | 63 +++++++++++++ .../components/xbox/binary_sensor.py | 84 +++++++++++++++++ homeassistant/components/xbox/const.py | 2 + homeassistant/components/xbox/media_player.py | 6 +- homeassistant/components/xbox/remote.py | 6 +- homeassistant/components/xbox/sensor.py | 83 ++++++++++++++++ 8 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/xbox/base_sensor.py create mode 100644 homeassistant/components/xbox/binary_sensor.py create mode 100644 homeassistant/components/xbox/sensor.py diff --git a/.coveragerc b/.coveragerc index e893f831ead..69c6aa7801d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1003,10 +1003,13 @@ omit = homeassistant/components/x10/light.py homeassistant/components/xbox/__init__.py homeassistant/components/xbox/api.py + homeassistant/components/xbox/base_sensor.py + homeassistant/components/xbox/binary_sensor.py homeassistant/components/xbox/browse_media.py homeassistant/components/xbox/media_player.py homeassistant/components/xbox/media_source.py homeassistant/components/xbox/remote.py + homeassistant/components/xbox/sensor.py homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xfinity/device_tracker.py diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 7de98e3dfdc..646e8e10ac7 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -9,6 +9,11 @@ import voluptuous as vol from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.const import HOME_APP_IDS, 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.smartglass.models import ( SmartglassConsoleList, SmartglassConsoleStatus, @@ -42,7 +47,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["media_player", "remote"] +PLATFORMS = ["media_player", "remote", "binary_sensor", "sensor"] async def async_setup(hass: HomeAssistant, config: dict): @@ -115,19 +120,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) ) if unload_ok: + # Unsub from coordinator updates + hass.data[DOMAIN][entry.entry_id]["sensor_unsub"]() + hass.data[DOMAIN][entry.entry_id]["binary_sensor_unsub"]() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @dataclass -class XboxData: - """Xbox dataclass for update coordinator.""" +class ConsoleData: + """Xbox console status data.""" status: SmartglassConsoleStatus app_details: Optional[Product] +@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: Optional[str] + account_tier: str + + +@dataclass +class XboxData: + """Xbox dataclass for update coordinator.""" + + consoles: Dict[str, ConsoleData] + presence: Dict[str, PresenceData] + + class XboxUpdateCoordinator(DataUpdateCoordinator): """Store Xbox Console Status.""" @@ -144,15 +177,16 @@ class XboxUpdateCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=10), ) - self.data: Dict[str, XboxData] = {} + self.data: XboxData = XboxData({}, []) self.client: XboxLiveClient = client self.consoles: SmartglassConsoleList = consoles - async def _async_update_data(self) -> Dict[str, XboxData]: + async def _async_update_data(self) -> XboxData: """Fetch the latest console status.""" - new_data: Dict[str, XboxData] = {} + # Update Console Status + new_console_data: Dict[str, ConsoleData] = {} for console in self.consoles.result: - current_state: Optional[XboxData] = self.data.get(console.id) + current_state: Optional[ConsoleData] = self.data.consoles.get(console.id) status: SmartglassConsoleStatus = ( await self.client.smartglass.get_console_status(console.id) ) @@ -195,6 +229,48 @@ class XboxUpdateCoordinator(DataUpdateCoordinator): ) app_details = catalog_result.products[0] - new_data[console.id] = XboxData(status=status, app_details=app_details) + new_console_data[console.id] = ConsoleData( + status=status, app_details=app_details + ) - return new_data + # Update user presence + presence_data = {} + 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) + + return XboxData(new_console_data, presence_data) + + +def _build_presence_data(person: Person) -> PresenceData: + """Build presence data from a person.""" + active_app: Optional[PresenceDetail] = None + try: + active_app = next( + presence for presence in person.presence_details if presence.is_primary + ) + except StopIteration: + pass + + 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=active_app and active_app.is_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, + ) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py new file mode 100644 index 00000000000..028f1d4c9ec --- /dev/null +++ b/homeassistant/components/xbox/base_sensor.py @@ -0,0 +1,63 @@ +"""Base Sensor for the Xbox Integration.""" +from typing import Optional + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PresenceData, XboxUpdateCoordinator +from .const import DOMAIN + + +class XboxBaseSensorEntity(CoordinatorEntity): + """Base Sensor for the Xbox Integration.""" + + def __init__(self, coordinator: XboxUpdateCoordinator, xuid: str, attribute: str): + """Initialize Xbox binary sensor.""" + super().__init__(coordinator) + self.xuid = xuid + self.attribute = attribute + + @property + def unique_id(self) -> str: + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self.xuid}_{self.attribute}" + + @property + def data(self) -> Optional[PresenceData]: + """Return coordinator data for this console.""" + return self.coordinator.data.presence.get(self.xuid) + + @property + def name(self) -> str: + """Return the name of the sensor.""" + if not self.data: + return None + + if self.attribute == "online": + return self.data.gamertag + + attr_name = " ".join([part.title() for part in self.attribute.split("_")]) + return f"{self.data.gamertag} {attr_name}" + + @property + def entity_picture(self) -> str: + """Return the gamer pic.""" + if not self.data: + return None + + return self.data.display_pic.replace("&mode=Padding", "") + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self.attribute == "online" + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, "xbox_live")}, + "name": "Xbox Live", + "manufacturer": "Microsoft", + "model": "Xbox Live", + "entry_type": "service", + } diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py new file mode 100644 index 00000000000..109b2839b68 --- /dev/null +++ b/homeassistant/components/xbox/binary_sensor.py @@ -0,0 +1,84 @@ +"""Xbox friends binary sensors.""" +from functools import partial +from typing import Dict, List + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import callback +from homeassistant.helpers.entity_registry import ( + async_get_registry as async_get_entity_registry, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import XboxUpdateCoordinator +from .base_sensor import XboxBaseSensorEntity +from .const import DOMAIN + +PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"] + + +async def async_setup_entry(hass: HomeAssistantType, config_entry, async_add_entities): + """Set up Xbox Live friends.""" + coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + "coordinator" + ] + + update_friends = partial(async_update_friends, coordinator, {}, async_add_entities) + + unsub = coordinator.async_add_listener(update_friends) + hass.data[DOMAIN][config_entry.entry_id]["binary_sensor_unsub"] = unsub + update_friends() + + +class XboxBinarySensorEntity(XboxBaseSensorEntity, BinarySensorEntity): + """Representation of a Xbox presence state.""" + + @property + def is_on(self) -> bool: + """Return the status of the requested attribute.""" + if not self.coordinator.last_update_success: + return False + + return getattr(self.data, self.attribute, False) + + +@callback +def async_update_friends( + coordinator: XboxUpdateCoordinator, + current: Dict[str, List[XboxBinarySensorEntity]], + async_add_entities, +) -> None: + """Update friends.""" + new_ids = set(coordinator.data.presence) + current_ids = set(current) + + # Process new favorites, add them to Home Assistant + new_entities = [] + for xuid in new_ids - current_ids: + current[xuid] = [ + XboxBinarySensorEntity(coordinator, xuid, attribute) + for attribute in PRESENCE_ATTRIBUTES + ] + new_entities = new_entities + current[xuid] + + if new_entities: + async_add_entities(new_entities) + + # Process deleted favorites, remove them from Home Assistant + for xuid in current_ids - new_ids: + coordinator.hass.async_create_task( + async_remove_entities(xuid, coordinator, current) + ) + + +async def async_remove_entities( + xuid: str, + coordinator: XboxUpdateCoordinator, + current: Dict[str, XboxBinarySensorEntity], +) -> None: + """Remove friend sensors from Home Assistant.""" + registry = await async_get_entity_registry(coordinator.hass) + entities = current[xuid] + for entity in entities: + if entity.entity_id in registry.entities: + registry.async_remove(entity.entity_id) + del current[xuid] diff --git a/homeassistant/components/xbox/const.py b/homeassistant/components/xbox/const.py index 8879ef6d907..1a38e0b3cdd 100644 --- a/homeassistant/components/xbox/const.py +++ b/homeassistant/components/xbox/const.py @@ -4,3 +4,5 @@ DOMAIN = "xbox" OAUTH2_AUTHORIZE = "https://login.live.com/oauth20_authorize.srf" OAUTH2_TOKEN = "https://login.live.com/oauth20_token.srf" + +EVENT_NEW_FAVORITE = "xbox/new_favorite" diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 23fd48fa4b0..9856904e2bd 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -31,7 +31,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import XboxData, XboxUpdateCoordinator +from . import ConsoleData, XboxUpdateCoordinator from .browse_media import build_item_response from .const import DOMAIN @@ -99,9 +99,9 @@ class XboxMediaPlayer(CoordinatorEntity, MediaPlayerEntity): return self._console.id @property - def data(self) -> XboxData: + def data(self) -> ConsoleData: """Return coordinator data for this console.""" - return self.coordinator.data[self._console.id] + return self.coordinator.data.consoles[self._console.id] @property def state(self): diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index bd6e6da0ac6..12d9a6336c8 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -19,7 +19,7 @@ from homeassistant.components.remote import ( ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import XboxData, XboxUpdateCoordinator +from . import ConsoleData, XboxUpdateCoordinator from .const import DOMAIN @@ -61,9 +61,9 @@ class XboxRemote(CoordinatorEntity, RemoteEntity): return self._console.id @property - def data(self) -> XboxData: + def data(self) -> ConsoleData: """Return coordinator data for this console.""" - return self.coordinator.data[self._console.id] + return self.coordinator.data.consoles[self._console.id] @property def is_on(self): diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py new file mode 100644 index 00000000000..e0ab661b2d9 --- /dev/null +++ b/homeassistant/components/xbox/sensor.py @@ -0,0 +1,83 @@ +"""Xbox friends binary sensors.""" +from functools import partial +from typing import Dict, List + +from homeassistant.core import callback +from homeassistant.helpers.entity_registry import ( + async_get_registry as async_get_entity_registry, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import XboxUpdateCoordinator +from .base_sensor import XboxBaseSensorEntity +from .const import DOMAIN + +SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] + + +async def async_setup_entry(hass: HomeAssistantType, config_entry, async_add_entities): + """Set up Xbox Live friends.""" + coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + "coordinator" + ] + + update_friends = partial(async_update_friends, coordinator, {}, async_add_entities) + + unsub = coordinator.async_add_listener(update_friends) + hass.data[DOMAIN][config_entry.entry_id]["sensor_unsub"] = unsub + update_friends() + + +class XboxSensorEntity(XboxBaseSensorEntity): + """Representation of a Xbox presence state.""" + + @property + def state(self): + """Return the state of the requested attribute.""" + if not self.coordinator.last_update_success: + return None + + return getattr(self.data, self.attribute, None) + + +@callback +def async_update_friends( + coordinator: XboxUpdateCoordinator, + current: Dict[str, List[XboxSensorEntity]], + async_add_entities, +) -> None: + """Update friends.""" + new_ids = set(coordinator.data.presence) + current_ids = set(current) + + # Process new favorites, add them to Home Assistant + new_entities = [] + for xuid in new_ids - current_ids: + current[xuid] = [ + XboxSensorEntity(coordinator, xuid, attribute) + for attribute in SENSOR_ATTRIBUTES + ] + new_entities = new_entities + current[xuid] + + if new_entities: + async_add_entities(new_entities) + + # Process deleted favorites, remove them from Home Assistant + for xuid in current_ids - new_ids: + coordinator.hass.async_create_task( + async_remove_entities(xuid, coordinator, current) + ) + + +async def async_remove_entities( + xuid: str, + coordinator: XboxUpdateCoordinator, + current: Dict[str, XboxSensorEntity], +) -> None: + """Remove friend sensors from Home Assistant.""" + registry = await async_get_entity_registry(coordinator.hass) + entities = current[xuid] + for entity in entities: + if entity.entity_id in registry.entities: + registry.async_remove(entity.entity_id) + del current[xuid]