Remove Subscribed to Xbox Game Pass binary sensor from Xbox integration (#182734)

This commit is contained in:
Manu
2026-09-20 09:02:57 +02:00
committed by GitHub
parent 0c0b074868
commit 7c55612d05
8 changed files with 288 additions and 160 deletions
+15 -2
View File
@@ -9,6 +9,7 @@ from pythonxbox.api.provider.people.models import Person
from pythonxbox.api.provider.titlehub.models import Title
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@@ -16,7 +17,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import XboxConfigEntry
from .entity import XboxBaseEntity, XboxBaseEntityDescription, profile_pic
from .entity import (
XboxBaseEntity,
XboxBaseEntityDescription,
check_deprecated_entity,
profile_pic,
)
PARALLEL_UPDATES = 0
@@ -81,7 +87,8 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
XboxBinarySensorEntityDescription(
key=XboxBinarySensor.HAS_GAME_PASS,
translation_key=XboxBinarySensor.HAS_GAME_PASS,
is_on_fn=lambda x: x.detail.has_game_pass if x.detail else None,
is_on_fn=lambda _: None,
deprecated=True,
),
)
@@ -100,6 +107,9 @@ async def async_setup_entry(
[
XboxBinarySensorEntity(coordinator, entry.unique_id, description)
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, entry.unique_id, description, BINARY_SENSOR_DOMAIN
)
]
)
@@ -109,6 +119,9 @@ async def async_setup_entry(
XboxBinarySensorEntity(coordinator, subentry.unique_id, description)
for description in SENSOR_DESCRIPTIONS
if subentry.unique_id
and check_deprecated_entity(
hass, subentry.unique_id, description, BINARY_SENSOR_DOMAIN
)
and subentry.unique_id in coordinator.data.presence
and subentry.subentry_type == "friend"
],
+60
View File
@@ -9,8 +9,17 @@ from pythonxbox.api.provider.smartglass.models import ConsoleType, SmartglassCon
from pythonxbox.api.provider.titlehub.models import Title
from yarl import URL
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -38,6 +47,7 @@ class XboxBaseEntityDescription(EntityDescription):
attributes_fn: Callable[[Person, Title | None], Mapping[str, Any] | None] | None = (
None
)
deprecated: bool | None = None
class XboxBaseEntity(CoordinatorEntity[XboxPresenceCoordinator]):
@@ -165,3 +175,53 @@ def profile_pic(person: Person, _: Title | None = None) -> str | None:
# We need to also remove the 'mode=Padding' query because with it,
# it results in an error 400.
return str(URL(to_https(person.display_pic_raw)).without_query_params("mode"))
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Get list of related automations and scripts."""
used_in = automations_with_entity(hass, entity_id)
used_in += scripts_with_entity(hass, entity_id)
return used_in
def check_deprecated_entity(
hass: HomeAssistant,
xuid: str,
entity_description: XboxBaseEntityDescription,
entity_domain: str,
) -> bool:
"""Check for deprecated entity and remove it."""
if not entity_description.deprecated:
return True
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(
entity_domain,
DOMAIN,
f"{xuid}_{entity_description.key}",
):
if (entity_entry := ent_reg.async_get(entity_id)) is not None:
if entity_used_in(hass, entity_id) and not entity_entry.disabled:
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{xuid}_{entity_description.key}",
breaks_in_ha_version="2027.4.0",
is_fixable=True,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"name": str(entity_entry.name or entity_entry.original_name),
"entity": entity_id,
},
data={"entity_id": entity_id},
)
return True
if not entity_used_in(hass, entity_id) or entity_entry.disabled:
ent_reg.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"deprecated_entity_{xuid}_{entity_description.key}",
)
return False
-3
View File
@@ -1,9 +1,6 @@
{
"entity": {
"binary_sensor": {
"has_game_pass": {
"default": "mdi:microsoft-xbox"
},
"in_game": {
"default": "mdi:microsoft-xbox-controller"
},
@@ -69,9 +69,7 @@ rules:
reconfiguration-flow:
status: exempt
comment: nothing to reconfigure
repair-issues:
status: exempt
comment: has no repairs
repair-issues: done
stale-devices: done
# Platinum
+58
View File
@@ -0,0 +1,58 @@
"""Repairs for Xbox integration."""
from typing import cast
import probatio
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import async_delete_issue
from .const import DOMAIN
class DeprecatedEntityRepairFlow(RepairsFlow):
"""Handler for a deprecated entity issue fixing flow."""
def __init__(
self, issue_id: str, data: dict[str, str | int | float | None]
) -> None:
"""Initialize."""
self._data = data
self._issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
er.async_get(self.hass).async_remove(cast(str, self._data["entity_id"]))
async_delete_issue(self.hass, DOMAIN, self._issue_id)
return self.async_create_entry(data={})
return self.async_show_form(step_id="confirm", data_schema=probatio.Schema({}))
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None
) -> RepairsFlow:
"""Create flow."""
if not data or "entity_id" not in data:
raise ValueError("Missing data for repair flow")
return (
DeprecatedEntityRepairFlow(issue_id, data)
if issue_id.startswith("deprecated_entity_")
else ConfirmRepairFlow()
)
@@ -174,5 +174,17 @@
"xbox_not_configured": {
"message": "The Xbox integration is not configured."
}
},
"issues": {
"deprecated_entity": {
"fix_flow": {
"step": {
"confirm": {
"description": "The Xbox entity `{entity}` is no longer functional and will be removed in a future release.\n\nPlease update any automations and scripts that use this entity, then press **Submit** to remove it now and resolve this issue."
}
}
},
"title": "The Xbox {name} entity is deprecated"
}
}
}
@@ -104,56 +104,6 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.erics273_subscribed_to_xbox_game_pass-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.erics273_subscribed_to_xbox_game_pass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Subscribed to Xbox Game Pass',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Subscribed to Xbox Game Pass',
'platform': 'xbox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <XboxBinarySensor.HAS_GAME_PASS: 'has_game_pass'>,
'unique_id': '2533274913657542_has_game_pass',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.erics273_subscribed_to_xbox_game_pass-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'erics273 Subscribed to Xbox Game Pass',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.erics273_subscribed_to_xbox_game_pass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.gsr_ae-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -259,56 +209,6 @@
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.gsr_ae_subscribed_to_xbox_game_pass-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.gsr_ae_subscribed_to_xbox_game_pass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Subscribed to Xbox Game Pass',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Subscribed to Xbox Game Pass',
'platform': 'xbox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <XboxBinarySensor.HAS_GAME_PASS: 'has_game_pass'>,
'unique_id': '271958441785640_has_game_pass',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.gsr_ae_subscribed_to_xbox_game_pass-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'GSR Ae Subscribed to Xbox Game Pass',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.gsr_ae_subscribed_to_xbox_game_pass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.ikken_hissatsuu-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -414,53 +314,3 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Subscribed to Xbox Game Pass',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Subscribed to Xbox Game Pass',
'platform': 'xbox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <XboxBinarySensor.HAS_GAME_PASS: 'has_game_pass'>,
'unique_id': '2533274838782903_has_game_pass',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Ikken Hissatsuu Subscribed to Xbox Game Pass',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+142 -2
View File
@@ -12,6 +12,10 @@ from pythonxbox.api.provider.smartglass.models import SmartglassConsoleList
from pythonxbox.common.exceptions import AuthenticationException
import respx
from homeassistant.components import automation
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.components.xbox.binary_sensor import XboxBinarySensor
from homeassistant.components.xbox.const import DOMAIN, OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -19,7 +23,11 @@ from homeassistant.exceptions import (
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
@@ -30,7 +38,7 @@ from tests.common import (
async_fire_time_changed,
async_load_json_object_fixture,
)
from tests.typing import WebSocketGenerator
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@pytest.mark.usefixtures("xbox_live_client")
@@ -277,3 +285,135 @@ async def test_dynamic_devices(
)
response = await client.remove_device(account.id)
assert not response["success"]
@pytest.mark.usefixtures("xbox_live_client", "entity_registry_enabled_by_default")
async def test_binary_sensor_deprecation_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
entity_registry: er.EntityRegistry,
hass_client: ClientSessionGenerator,
) -> None:
"""Test sensor deprecation issue."""
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
entity_registry.async_get_or_create(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"271958441785640_{XboxBinarySensor.HAS_GAME_PASS}",
suggested_object_id="gsr_ae_subscribed_to_xbox_game_pass",
disabled_by=None,
)
assert entity_registry is not None
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"id": "test",
"alias": "test",
"trigger": {
"platform": "state",
"entity_id": f"{BINARY_SENSOR_DOMAIN}.gsr_ae_subscribed_to_xbox_game_pass",
},
"action": {
"action": "automation.turn_on",
"target": {
"entity_id": "automation.test",
},
},
}
},
)
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.LOADED
assert (
entity_registry.async_get(
f"binary_sensor.{'gsr_ae_subscribed_to_xbox_game_pass'}"
)
is not None
)
assert (
repair_issue := issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"deprecated_entity_271958441785640_{XboxBinarySensor.HAS_GAME_PASS}",
)
)
client = await hass_client()
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": DOMAIN, "issue_id": repair_issue.issue_id},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"type": "create_entry",
"flow_id": flow_id,
"handler": DOMAIN,
"description": None,
"description_placeholders": None,
}
assert not issue_registry.async_get_issue(
DOMAIN,
f"deprecated_entity_271958441785640_{XboxBinarySensor.HAS_GAME_PASS}",
)
assert (
entity_registry.async_get(
f"binary_sensor.{'gsr_ae_subscribed_to_xbox_game_pass'}"
)
is None
)
@pytest.mark.usefixtures("xbox_live_client", "entity_registry_enabled_by_default")
async def test_binary_sensor_deprecation_remove_disabled(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test we remove a deprecated sensor."""
entity_registry.async_get_or_create(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"271958441785640_{XboxBinarySensor.HAS_GAME_PASS}",
suggested_object_id="gsr_ae_subscribed_to_xbox_game_pass",
disabled_by=er.RegistryEntryDisabler.USER,
)
assert entity_registry is not None
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.LOADED
assert (
entity_registry.async_get(
f"binary_sensor.{'gsr_ae_subscribed_to_xbox_game_pass'}"
)
is None
)