From 07b6358fff4d088e5b51a1971df0d39a9b661b29 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 26 Oct 2025 07:20:24 +0200 Subject: [PATCH] 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 780e9f418a5..6cdbc11ae04 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -162,6 +162,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): 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 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): 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 70f845404cd..4a0208f1c85 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 59e3fc68cf7..0bd93d3ea37 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -59,6 +59,7 @@ from homeassistant.const import ( 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.test_util.aiohttp import AiohttpClientMocker 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