diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 74cc935bff98..c9b19ce52c03 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -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" ], diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 3faddd0cd78c..fa1cd238fc13 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -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 diff --git a/homeassistant/components/xbox/icons.json b/homeassistant/components/xbox/icons.json index 4383aa30df63..54fad3fa9301 100644 --- a/homeassistant/components/xbox/icons.json +++ b/homeassistant/components/xbox/icons.json @@ -1,9 +1,6 @@ { "entity": { "binary_sensor": { - "has_game_pass": { - "default": "mdi:microsoft-xbox" - }, "in_game": { "default": "mdi:microsoft-xbox-controller" }, diff --git a/homeassistant/components/xbox/quality_scale.yaml b/homeassistant/components/xbox/quality_scale.yaml index b63da3760ce0..25d9f349e02a 100644 --- a/homeassistant/components/xbox/quality_scale.yaml +++ b/homeassistant/components/xbox/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/xbox/repairs.py b/homeassistant/components/xbox/repairs.py new file mode 100644 index 000000000000..27ec103b0b30 --- /dev/null +++ b/homeassistant/components/xbox/repairs.py @@ -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() + ) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index c2996d8d529b..4008026bef06 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -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" + } } } diff --git a/tests/components/xbox/snapshots/test_binary_sensor.ambr b/tests/components/xbox/snapshots/test_binary_sensor.ambr index b6c560548adf..6a89059c5789 100644 --- a/tests/components/xbox/snapshots/test_binary_sensor.ambr +++ b/tests/components/xbox/snapshots/test_binary_sensor.ambr @@ -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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - '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': , - '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': , - '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({ - : 'erics273 Subscribed to Xbox Game Pass', - }), - 'context': , - 'entity_id': 'binary_sensor.erics273_subscribed_to_xbox_game_pass', - 'last_changed': , - 'last_reported': , - 'last_updated': , - '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - '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': , - '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': , - '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({ - : 'GSR Ae Subscribed to Xbox Game Pass', - }), - 'context': , - 'entity_id': 'binary_sensor.gsr_ae_subscribed_to_xbox_game_pass', - 'last_changed': , - 'last_reported': , - 'last_updated': , - '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - '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': , - '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': , - '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({ - : 'Ikken Hissatsuu Subscribed to Xbox Game Pass', - }), - 'context': , - 'entity_id': 'binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 3b9ab66dd33e..e1bc5ec974d3 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -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 + )