From 3c46b40cee5bb15155bd3b2c9883c77c41a3551c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 26 Oct 2025 00:04:24 -0400 Subject: [PATCH 1/3] Raise an issue when the Roborock local api is unavailable. (#154576) Co-authored-by: Josef Zweck Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 21 ++++++++++ .../components/roborock/strings.json | 7 ++++ tests/components/roborock/test_coordinator.py | 39 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 192a52f925d4a0..d93cf779ad9392 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -38,6 +38,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util, slugify @@ -274,6 +279,9 @@ async def _verify_api(self) -> None: try: await self.api.async_connect() await self.api.ping() + async_delete_issue( + self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}" + ) except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", @@ -284,6 +292,19 @@ async def _verify_api(self) -> None: self.api = self.cloud_api self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL self._is_cloud_api = True + async_create_issue( + self.hass, + DOMAIN, + f"cloud_api_used_{self.duid_slug}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="cloud_api_used", + translation_placeholders={ + "device_name": self.roborock_device_info.device.name + }, + learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues", + ) + # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index a8f58cf24923da..8a00fc9445c9c8 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -39,6 +39,13 @@ "wrong_account": "Wrong account: Please authenticate with the right account." } }, + "issues": { + "cloud_api_used": { + "title": "Cloud API used", + "description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance." + } + }, + "options": { "step": { "drawables": { diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index cf2ead85cb2f25..77b87752dc6b71 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -22,6 +22,7 @@ from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util import dt as dt_util from .mock_data import PROP @@ -171,6 +172,44 @@ async def test_no_maps( assert load_map.call_count == 0 +async def test_cloud_api_repair( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, +) -> None: + """Test that a repair is created when we use the cloud api.""" + # Force the system to use the cloud api. + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", + side_effect=RoborockException(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 2 + # Check that both expected device names are present, regardless of order + assert all( + issue.translation_key == "cloud_api_used" + for issue in issue_registry.issues.values() + ) + names = { + issue.translation_placeholders["device_name"] + for issue in issue_registry.issues.values() + } + assert names == {"Roborock S7 MaxV", "Roborock S7 2"} + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + # Now change to using the local api + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping" + ): + # Set it back up + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 0 + + async def test_two_maps_in_cleaning( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, From 6fa73f7f6a96d8e00db033da60694f586fcbde98 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 26 Oct 2025 06:13:45 +0100 Subject: [PATCH 2/3] Deprecate entities in Xbox integration (#154891) --- .../components/xbox/binary_sensor.py | 36 +-- homeassistant/components/xbox/entity.py | 30 ++ homeassistant/components/xbox/icons.json | 12 - homeassistant/components/xbox/sensor.py | 28 +- homeassistant/components/xbox/strings.json | 12 - .../xbox/snapshots/test_binary_sensor.ambr | 288 ------------------ .../xbox/snapshots/test_sensor.ambr | 288 ------------------ tests/components/xbox/test_binary_sensor.py | 39 +++ tests/components/xbox/test_sensor.py | 39 +++ 9 files changed, 140 insertions(+), 632 deletions(-) diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index b3c1fc7ce63510..e2d7458a64f933 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -11,6 +11,7 @@ from yarl import URL from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import XboxConfigEntry, XboxUpdateCoordinator -from .entity import XboxBaseEntity +from .entity import XboxBaseEntity, check_deprecated_entity class XboxBinarySensor(StrEnum): @@ -37,6 +38,7 @@ class XboxBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_fn: Callable[[Person], bool | None] entity_picture_fn: Callable[[Person], str | None] | None = None + deprecated: bool | None = None def profile_pic(person: Person) -> str | None: @@ -82,13 +84,8 @@ def in_game(person: Person) -> bool: ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_PARTY, - translation_key=XboxBinarySensor.IN_PARTY, - is_on_fn=( - lambda x: bool(x.multiplayer_summary.in_party) - if x.multiplayer_summary - else None - ), - entity_registry_enabled_default=False, + is_on_fn=lambda _: None, + deprecated=True, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_GAME, @@ -97,13 +94,8 @@ def in_game(person: Person) -> bool: ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.IN_MULTIPLAYER, - translation_key=XboxBinarySensor.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, + is_on_fn=lambda _: None, + deprecated=True, ), XboxBinarySensorEntityDescription( key=XboxBinarySensor.HAS_GAME_PASS, @@ -121,7 +113,9 @@ async def async_setup_entry( """Set up Xbox Live friends.""" coordinator = entry.runtime_data - update_friends = partial(async_update_friends, coordinator, {}, async_add_entities) + update_friends = partial( + async_update_friends, hass, coordinator, {}, async_add_entities + ) entry.async_on_unload(coordinator.async_add_listener(update_friends)) @@ -152,6 +146,7 @@ def entity_picture(self) -> str | None: @callback def async_update_friends( + hass: HomeAssistant, coordinator: XboxUpdateCoordinator, current: dict[str, list[XboxBinarySensorEntity]], async_add_entities, @@ -163,10 +158,11 @@ def async_update_friends( # Process new favorites, add them to Home Assistant new_entities: list[XboxBinarySensorEntity] = [] for xuid in new_ids - current_ids: - current[xuid] = [ - XboxBinarySensorEntity(coordinator, xuid, description) - for description in SENSOR_DESCRIPTIONS - ] + current[xuid] = [] + for description in SENSOR_DESCRIPTIONS: + entity = XboxBinarySensorEntity(coordinator, xuid, description) + if check_deprecated_entity(hass, entity, BINARY_SENSOR_DOMAIN): + current[xuid].append(entity) new_entities = new_entities + current[xuid] if new_entities: async_add_entities(new_entities) diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 89f9076906cd56..3424dbc9cc9086 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -4,6 +4,10 @@ from xbox.webapi.api.provider.smartglass.models import ConsoleType, SmartglassConsole +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.update_coordinator import CoordinatorEntity @@ -83,3 +87,29 @@ def __init__( def data(self) -> ConsoleData: """Return coordinator data for this console.""" return self.coordinator.data.consoles[self._console.id] + + +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, + entity: XboxBaseEntity, + entity_domain: str, +) -> bool: + """Check for deprecated entity and remove it.""" + if not getattr(entity.entity_description, "deprecated", False): + return True + ent_reg = er.async_get(hass) + if entity_id := ent_reg.async_get_entity_id( + entity_domain, + DOMAIN, + f"{entity.xuid}_{entity.entity_description.key}", + ): + ent_reg.async_remove(entity_id) + + return False diff --git a/homeassistant/components/xbox/icons.json b/homeassistant/components/xbox/icons.json index 18fa131c59a59c..44f923bc584a1d 100644 --- a/homeassistant/components/xbox/icons.json +++ b/homeassistant/components/xbox/icons.json @@ -7,12 +7,6 @@ "gamer_score": { "default": "mdi:alpha-g-circle" }, - "account_tier": { - "default": "mdi:microsoft-xbox" - }, - "gold_tenure": { - "default": "mdi:microsoft-xbox" - }, "last_online": { "default": "mdi:account-clock" }, @@ -27,15 +21,9 @@ "online": { "default": "mdi:account" }, - "in_party": { - "default": "mdi:account-group" - }, "in_game": { "default": "mdi:microsoft-xbox-controller" }, - "in_multiplayer": { - "default": "mdi:account-multiple" - }, "has_game_pass": { "default": "mdi:microsoft-xbox" } diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index d2fd639645e525..0f7dc40426eaa0 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -11,6 +11,7 @@ from xbox.webapi.api.provider.people.models import Person from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -20,7 +21,7 @@ from homeassistant.helpers.typing import StateType from .coordinator import XboxConfigEntry, XboxUpdateCoordinator -from .entity import XboxBaseEntity +from .entity import XboxBaseEntity, check_deprecated_entity class XboxSensor(StrEnum): @@ -40,6 +41,7 @@ class XboxSensorEntityDescription(SensorEntityDescription): """Xbox sensor description.""" value_fn: Callable[[Person], StateType | datetime] + deprecated: bool | None = None SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = ( @@ -55,15 +57,13 @@ class XboxSensorEntityDescription(SensorEntityDescription): ), XboxSensorEntityDescription( key=XboxSensor.ACCOUNT_TIER, - translation_key=XboxSensor.ACCOUNT_TIER, - entity_registry_enabled_default=False, - value_fn=lambda x: x.detail.account_tier if x.detail else None, + value_fn=lambda _: None, + deprecated=True, ), XboxSensorEntityDescription( key=XboxSensor.GOLD_TENURE, - translation_key=XboxSensor.GOLD_TENURE, - entity_registry_enabled_default=False, - value_fn=lambda x: x.detail.tenure if x.detail else None, + value_fn=lambda _: None, + deprecated=True, ), XboxSensorEntityDescription( key=XboxSensor.LAST_ONLINE, @@ -96,7 +96,9 @@ async def async_setup_entry( """Set up Xbox Live friends.""" coordinator = config_entry.runtime_data - update_friends = partial(async_update_friends, coordinator, {}, async_add_entities) + update_friends = partial( + async_update_friends, hass, coordinator, {}, async_add_entities + ) config_entry.async_on_unload(coordinator.async_add_listener(update_friends)) update_friends() @@ -115,6 +117,7 @@ def native_value(self) -> StateType | datetime: @callback def async_update_friends( + hass: HomeAssistant, coordinator: XboxUpdateCoordinator, current: dict[str, list[XboxSensorEntity]], async_add_entities, @@ -126,10 +129,11 @@ def async_update_friends( # Process new favorites, add them to Home Assistant new_entities: list[XboxSensorEntity] = [] for xuid in new_ids - current_ids: - current[xuid] = [ - XboxSensorEntity(coordinator, xuid, description) - for description in SENSOR_DESCRIPTIONS - ] + current[xuid] = [] + for description in SENSOR_DESCRIPTIONS: + entity = XboxSensorEntity(coordinator, xuid, description) + if check_deprecated_entity(hass, entity, SENSOR_DOMAIN): + current[xuid].append(entity) new_entities = new_entities + current[xuid] if new_entities: async_add_entities(new_entities) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index d3b33b145896f6..a40531e1646d79 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -37,12 +37,6 @@ "name": "Gamerscore", "unit_of_measurement": "points" }, - "account_tier": { - "name": "Account tier" - }, - "gold_tenure": { - "name": "Gold tenure" - }, "last_online": { "name": "Last online" }, @@ -56,15 +50,9 @@ } }, "binary_sensor": { - "in_party": { - "name": "In party" - }, "in_game": { "name": "In game" }, - "in_multiplayer": { - "name": "In multiplayer" - }, "has_game_pass": { "name": "Subscribed to Xbox Game Pass" } diff --git a/tests/components/xbox/snapshots/test_binary_sensor.ambr b/tests/components/xbox/snapshots/test_binary_sensor.ambr index 85ff7eae16e59b..991008da4d8708 100644 --- a/tests/components/xbox/snapshots/test_binary_sensor.ambr +++ b/tests/components/xbox/snapshots/test_binary_sensor.ambr @@ -96,102 +96,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[binary_sensor.erics273_in_multiplayer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_multiplayer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In multiplayer', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274913657542_in_multiplayer', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.erics273_in_multiplayer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'erics273 In multiplayer', - }), - 'context': , - 'entity_id': 'binary_sensor.erics273_in_multiplayer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[binary_sensor.erics273_in_party-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_party', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In party', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274913657542_in_party', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.erics273_in_party-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'erics273 In party', - }), - 'context': , - 'entity_id': 'binary_sensor.erics273_in_party', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.erics273_subscribed_to_xbox_game_pass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -337,102 +241,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[binary_sensor.gsr_ae_in_multiplayer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_multiplayer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In multiplayer', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '271958441785640_in_multiplayer', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.gsr_ae_in_multiplayer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GSR Ae In multiplayer', - }), - 'context': , - 'entity_id': 'binary_sensor.gsr_ae_in_multiplayer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[binary_sensor.gsr_ae_in_party-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_party', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In party', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '271958441785640_in_party', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.gsr_ae_in_party-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GSR Ae In party', - }), - 'context': , - 'entity_id': 'binary_sensor.gsr_ae_in_party', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.gsr_ae_subscribed_to_xbox_game_pass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -578,102 +386,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_in_multiplayer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_multiplayer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In multiplayer', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274838782903_in_multiplayer', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_in_multiplayer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ikken Hissatsuu In multiplayer', - }), - 'context': , - 'entity_id': 'binary_sensor.ikken_hissatsuu_in_multiplayer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_in_party-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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_in_party', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'In party', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274838782903_in_party', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.ikken_hissatsuu_in_party-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ikken Hissatsuu In party', - }), - 'context': , - 'entity_id': 'binary_sensor.ikken_hissatsuu_in_party', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[binary_sensor.ikken_hissatsuu_subscribed_to_xbox_game_pass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/xbox/snapshots/test_sensor.ambr b/tests/components/xbox/snapshots/test_sensor.ambr index eff4d3aa616215..17e05f33116ad7 100644 --- a/tests/components/xbox/snapshots/test_sensor.ambr +++ b/tests/components/xbox/snapshots/test_sensor.ambr @@ -1,52 +1,4 @@ # serializer version: 1 -# name: test_sensors[sensor.erics273_account_tier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.erics273_account_tier', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Account tier', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274913657542_account_tier', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.erics273_account_tier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'erics273 Account tier', - }), - 'context': , - 'entity_id': 'sensor.erics273_account_tier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Silver', - }) -# --- # name: test_sensors[sensor.erics273_follower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -194,54 +146,6 @@ 'state': '3802', }) # --- -# name: test_sensors[sensor.erics273_gold_tenure-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.erics273_gold_tenure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Gold tenure', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274913657542_gold_tenure', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.erics273_gold_tenure-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'erics273 Gold tenure', - }), - 'context': , - 'entity_id': 'sensor.erics273_gold_tenure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensors[sensor.erics273_last_online-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -339,54 +243,6 @@ 'state': 'Last seen 17h ago: Home', }) # --- -# name: test_sensors[sensor.gsr_ae_account_tier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gsr_ae_account_tier', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Account tier', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '271958441785640_account_tier', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.gsr_ae_account_tier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GSR Ae Account tier', - }), - 'context': , - 'entity_id': 'sensor.gsr_ae_account_tier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Gold', - }) -# --- # name: test_sensors[sensor.gsr_ae_follower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -534,54 +390,6 @@ 'state': '27750', }) # --- -# name: test_sensors[sensor.gsr_ae_gold_tenure-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gsr_ae_gold_tenure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Gold tenure', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '271958441785640_gold_tenure', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.gsr_ae_gold_tenure-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GSR Ae Gold tenure', - }), - 'context': , - 'entity_id': 'sensor.gsr_ae_gold_tenure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_sensors[sensor.gsr_ae_last_online-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -679,54 +487,6 @@ 'state': 'Last seen 49m ago: Xbox App', }) # --- -# name: test_sensors[sensor.ikken_hissatsuu_account_tier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ikken_hissatsuu_account_tier', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Account tier', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274838782903_account_tier', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.ikken_hissatsuu_account_tier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ikken Hissatsuu Account tier', - }), - 'context': , - 'entity_id': 'sensor.ikken_hissatsuu_account_tier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Gold', - }) -# --- # name: test_sensors[sensor.ikken_hissatsuu_follower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -874,54 +634,6 @@ 'state': '27210', }) # --- -# name: test_sensors[sensor.ikken_hissatsuu_gold_tenure-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ikken_hissatsuu_gold_tenure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Gold tenure', - 'platform': 'xbox', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '2533274838782903_gold_tenure', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.ikken_hissatsuu_gold_tenure-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ikken Hissatsuu Gold tenure', - }), - 'context': , - 'entity_id': 'sensor.ikken_hissatsuu_gold_tenure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- # name: test_sensors[sensor.ikken_hissatsuu_last_online-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/xbox/test_binary_sensor.py b/tests/components/xbox/test_binary_sensor.py index f40066d75144e1..9bd3f8437d83de 100644 --- a/tests/components/xbox/test_binary_sensor.py +++ b/tests/components/xbox/test_binary_sensor.py @@ -6,6 +6,9 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.xbox.binary_sensor import XboxBinarySensor +from homeassistant.components.xbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -40,3 +43,39 @@ async def test_binary_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("gsr_ae_in_multiplayer", XboxBinarySensor.IN_MULTIPLAYER), + ("gsr_ae_in_party", XboxBinarySensor.IN_PARTY), + ], +) +@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, + entity_id: str, + key: XboxBinarySensor, +) -> None: + """Test we remove a deprecated binary sensor.""" + + entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"271958441785640_{key}", + suggested_object_id=entity_id, + ) + + 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.{entity_id}") is None diff --git a/tests/components/xbox/test_sensor.py b/tests/components/xbox/test_sensor.py index 727a946c98a95b..830084edde21b1 100644 --- a/tests/components/xbox/test_sensor.py +++ b/tests/components/xbox/test_sensor.py @@ -6,6 +6,9 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.xbox.const import DOMAIN +from homeassistant.components.xbox.sensor import XboxSensor from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -40,3 +43,39 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("gsr_ae_account_tier", XboxSensor.ACCOUNT_TIER), + ("gsr_ae_gold_tenure", XboxSensor.GOLD_TENURE), + ], +) +@pytest.mark.usefixtures("xbox_live_client", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_remove_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: XboxSensor, +) -> None: + """Test we remove a deprecated sensor.""" + + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"271958441785640_{key}", + suggested_object_id=entity_id, + ) + + 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"sensor.{entity_id}") is None From 07b6358fff4d088e5b51a1971df0d39a9b661b29 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 26 Oct 2025 07:20:24 +0200 Subject: [PATCH 3/3] Fix LG webOS TV entity availability status (#155164) --- .../components/webostv/media_player.py | 27 +++-- .../components/webostv/quality_scale.yaml | 4 +- tests/components/webostv/test_media_player.py | 102 ++++++++++++++++-- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 780e9f418a565d..6cdbc11ae04b4e 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -162,6 +162,7 @@ def __init__(self, entry: WebOsTvConfigEntry) -> None: self._entry = entry self._client = entry.runtime_data self._attr_assumed_state = True + self._unavailable_logged = False self._device_name = entry.title self._attr_unique_id = entry.unique_id self._sources = entry.options.get(CONF_SOURCES) @@ -348,19 +349,31 @@ def _update_sources(self) -> None: ): self._source_list["Live TV"] = app + def _set_availability(self, available: bool) -> None: + """Set availability and log changes only once.""" + self._attr_available = available + if not available and not self._unavailable_logged: + _LOGGER.info("LG webOS TV entity %s is unavailable", self.entity_id) + self._unavailable_logged = True + elif available and self._unavailable_logged: + _LOGGER.info("LG webOS TV entity %s is back online", self.entity_id) + self._unavailable_logged = False + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) async def async_update(self) -> None: """Connect.""" if self._client.is_connected(): return - with suppress(*WEBOSTV_EXCEPTIONS): - try: - await self._client.connect() - except WebOsTvPairError: - self._entry.async_start_reauth(self.hass) - else: - update_client_key(self.hass, self._entry) + try: + await self._client.connect() + except WEBOSTV_EXCEPTIONS: + self._set_availability(bool(self._turn_on)) + except WebOsTvPairError: + self._entry.async_start_reauth(self.hass) + else: + self._set_availability(True) + update_client_key(self.hass, self._entry) @property def supported_features(self) -> MediaPlayerEntityFeature: diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 70f845404cd09b..4a0208f1c85d20 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -26,9 +26,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 59e3fc68cf7740..0bd93d3ea37202 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -59,6 +59,7 @@ SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -73,6 +74,16 @@ from tests.typing import ClientSessionGenerator +async def mock_scan_interval( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Mock update interval to force an update.""" + freezer.tick(timedelta(seconds=11)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("service", "attr_data", "client_call"), [ @@ -488,9 +499,7 @@ async def test_client_disconnected( client.is_connected.return_value = False client.connect.side_effect = TimeoutError - freezer.tick(timedelta(seconds=20)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await mock_scan_interval(hass, freezer) assert "TimeoutError" not in caplog.text @@ -506,9 +515,7 @@ async def test_client_key_update_on_connect( client.is_connected.return_value = False client.client_key = "new_key" - freezer.tick(timedelta(seconds=20)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await mock_scan_interval(hass, freezer) assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key @@ -849,9 +856,7 @@ async def test_reauth_reconnect( assert entry.state is ConfigEntryState.LOADED - freezer.tick(timedelta(seconds=20)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await mock_scan_interval(hass, freezer) assert entry.state is ConfigEntryState.LOADED @@ -886,3 +891,82 @@ async def test_update_media_state(hass: HomeAssistant, client) -> None: client.tv_state.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF + + +async def test_availability( + hass: HomeAssistant, + client, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that availability status changes are set and logged correctly.""" + await setup_webostv(hass) + + # Initially available + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.ON + + # Make the entity go offline - should log unavailable message + client.connect.side_effect = TimeoutError + client.is_connected.return_value = False + await mock_scan_interval(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + unavailable_log = f"LG webOS TV entity {ENTITY_ID} is unavailable" + assert unavailable_log in caplog.text + + # Clear logs and update the offline entity again - should NOT log again + caplog.clear() + await mock_scan_interval(hass, freezer) + + assert unavailable_log not in caplog.text + + # Bring the entity back online - should log back online message + client.connect.side_effect = None + await mock_scan_interval(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.ON + available_log = f"LG webOS TV entity {ENTITY_ID} is back online" + assert available_log in caplog.text + + # Clear logs and make update again - should NOT log again + caplog.clear() + await mock_scan_interval(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.ON + assert available_log not in caplog.text + + # Test offline again to ensure the flag resets properly + client.connect.side_effect = TimeoutError + await mock_scan_interval(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + assert unavailable_log in caplog.text + + # Test entity that supports turn on are considered available + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await mock_scan_interval(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.ON + available_log = f"LG webOS TV entity {ENTITY_ID} is back online" + assert available_log in caplog.text