Add Coordinator to LG WebOS TV (#177627)

This commit is contained in:
Shay Levy
2026-07-30 13:19:14 +03:00
committed by GitHub
parent 153baf136b
commit da7f25b244
9 changed files with 162 additions and 93 deletions
+12 -6
View File
@@ -20,7 +20,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS
from .helpers import WebOsTvConfigEntry, update_client_key
from .coordinator import (
WebOsTvConfigEntry,
WebOsTvDataUpdateCoordinator,
update_client_key,
)
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -39,9 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
key = entry.data[CONF_CLIENT_SECRET]
# Attempt a connection, but fail gracefully if tv is off for example.
entry.runtime_data = client = WebOsClient(
host, key, client_session=async_get_clientsession(hass)
)
client = WebOsClient(host, key, client_session=async_get_clientsession(hass))
with suppress(*WEBOSTV_EXCEPTIONS):
try:
await client.connect()
@@ -49,11 +51,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"device": entry.title},
) from err
# If pairing request accepted there will be no error
# Update the stored key without triggering reauth
update_client_key(hass, entry)
update_client_key(hass, entry, client)
entry.runtime_data = coordinator = WebOsTvDataUpdateCoordinator(hass, entry, client)
await client.register_state_update_callback(coordinator.async_handle_update)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -86,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
client = entry.runtime_data
client = entry.runtime_data.client
await hass_notify.async_reload(hass, DOMAIN)
client.clear_state_update_callbacks()
await client.disconnect()
@@ -1,6 +1,7 @@
"""Constants for the LG webOS TV integration."""
import asyncio
import logging
import aiohttp
from aiowebostv import WebOsTvCommandError
@@ -8,6 +9,7 @@ from aiowebostv import WebOsTvCommandError
from homeassistant.const import Platform
DOMAIN = "webostv"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.MEDIA_PLAYER]
DEFAULT_NAME = "LG webOS TV"
@@ -0,0 +1,86 @@
"""Coordinator for the LG webOS TV integration."""
from datetime import timedelta
from typing import override
from aiowebostv import WebOsClient, WebOsTvPairError, WebOsTvState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.trigger import PluggableAction
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, WEBOSTV_EXCEPTIONS
SCAN_INTERVAL = timedelta(seconds=10)
type WebOsTvConfigEntry = ConfigEntry[WebOsTvDataUpdateCoordinator]
class WebOsTvDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for the LG webOS TV integration."""
config_entry: WebOsTvConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: WebOsTvConfigEntry,
client: WebOsClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=SCAN_INTERVAL,
)
self.client = client
self.turn_on = PluggableAction(self.async_update_listeners)
@override
async def _async_update_data(self) -> None:
"""Connect to LG webOS TV if not connected."""
if self.client.is_connected():
return
try:
await self.client.connect()
except WEBOSTV_EXCEPTIONS as error:
if not self.turn_on:
# can't recover if the TV is disconnected and no turn_on action
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="device_unavailable",
translation_placeholders={"device": self.name},
) from error
except WebOsTvPairError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"device": self.name},
) from error
else:
update_client_key(self.hass, self.config_entry, self.client)
async def async_handle_update(self, tv_state: WebOsTvState) -> None:
"""Handle state update from TV."""
if self.last_update_success:
# client.connect() trigger an update on failure,
# avoid marking the device as available
self.async_set_updated_data(None)
def update_client_key(
hass: HomeAssistant, entry: WebOsTvConfigEntry, client: WebOsClient
) -> None:
"""Check and update stored client key if key has changed."""
if client.client_key != entry.data[CONF_CLIENT_SECRET]:
host = entry.data[CONF_HOST]
LOGGER.debug("Updating client key for host %s", host)
data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
hass.config_entries.async_update_entry(entry, data=data)
@@ -26,7 +26,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: WebOsTvConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
client: WebOsClient = entry.runtime_data
client: WebOsClient = entry.runtime_data.client
client_data = {
"is_registered": client.is_registered(),
+1 -21
View File
@@ -1,11 +1,7 @@
"""Helper functions for LG webOS TV."""
import logging
from aiowebostv import WebOsTvState
from aiowebostv import WebOsClient, WebOsTvState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -13,10 +9,6 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN, LIVE_TV_APP_ID
_LOGGER = logging.getLogger(__name__)
type WebOsTvConfigEntry = ConfigEntry[WebOsClient]
@callback
def async_get_device_entry_by_device_id(
@@ -75,15 +67,3 @@ def get_sources(tv_state: WebOsTvState) -> list[str]:
# Preserve order when filtering duplicates
return list(dict.fromkeys(sources))
def update_client_key(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None:
"""Check and update stored client key if key has changed."""
client: WebOsClient = entry.runtime_data
host = entry.data[CONF_HOST]
key = entry.data[CONF_CLIENT_SECRET]
if client.client_key != key:
_LOGGER.debug("Updating client key for host %s", host)
data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
hass.config_entries.async_update_entry(entry, data=data)
@@ -3,15 +3,10 @@
import asyncio
from collections.abc import Callable, Coroutine
from contextlib import suppress
from datetime import timedelta
from functools import wraps
from http import HTTPStatus
import logging
from typing import Any, Concatenate, cast, override
from aiowebostv import WebOsTvPairError, WebOsTvState
from homeassistant import util
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
@@ -20,13 +15,13 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, ServiceResponse
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.trigger import PluggableAction
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_PAYLOAD,
@@ -34,13 +29,12 @@ from .const import (
CONF_SOURCES,
DOMAIN,
LIVE_TV_APP_ID,
LOGGER,
WEBOSTV_EXCEPTIONS,
)
from .helpers import WebOsTvConfigEntry, update_client_key
from .coordinator import WebOsTvConfigEntry, WebOsTvDataUpdateCoordinator
from .triggers.turn_on import async_get_turn_on_trigger
_LOGGER = logging.getLogger(__name__)
SUPPORT_WEBOSTV = (
MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.NEXT_TRACK
@@ -56,10 +50,7 @@ SUPPORT_WEBOSTV_VOLUME = (
MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP
)
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=10)
async def async_setup_entry(
@@ -107,7 +98,9 @@ def cmd[_R, **_P](
return cmd_wrapper
class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
class LgWebOSMediaPlayerEntity(
CoordinatorEntity[WebOsTvDataUpdateCoordinator], RestoreEntity, MediaPlayerEntity
):
"""Representation of a LG webOS TV."""
_attr_device_class = MediaPlayerDeviceClass.TV
@@ -116,17 +109,16 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
def __init__(self, entry: WebOsTvConfigEntry) -> None:
"""Initialize the webos device."""
super().__init__(entry.runtime_data)
self._entry = entry
self._client = entry.runtime_data
self._client = entry.runtime_data.client
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)
# Assume that the TV is not paused
self._paused = False
self._turn_on = PluggableAction(self.async_write_ha_state)
self._current_source = None
self._source_list: dict = {}
@@ -140,15 +132,11 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
if (entry := self.registry_entry) and entry.device_id:
self.async_on_remove(
self._turn_on.async_register(
self.coordinator.turn_on.async_register(
self.hass, async_get_turn_on_trigger(entry.device_id)
)
)
await self._client.register_state_update_callback(
self.async_handle_state_update
)
if (
self.state == MediaPlayerState.OFF
and (state := await self.async_get_last_state()) is not None
@@ -161,12 +149,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
& ~MediaPlayerEntityFeature.TURN_ON
)
@override
async def async_will_remove_from_hass(self) -> None:
"""Call disconnect on removal."""
self._client.unregister_state_update_callback(self.async_handle_state_update)
self.async_on_remove(self.coordinator.async_add_listener(self._update_callback))
async def async_handle_state_update(self, tv_state: WebOsTvState) -> None:
@callback
def _update_callback(self) -> None:
"""Update state from WebOsClient."""
self._update_states()
self.async_write_ha_state()
@@ -309,37 +295,11 @@ 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
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
@override
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
if self._turn_on:
if self.coordinator.turn_on:
return self._supported_features | MediaPlayerEntityFeature.TURN_ON
return self._supported_features
@@ -353,7 +313,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
@override
async def async_turn_on(self) -> None:
"""Turn on media player."""
await self._turn_on.async_run(self.hass, self._context)
await self.coordinator.turn_on.async_run(self.hass, self._context)
@cmd
@override
@@ -418,10 +378,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
_LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
if media_type == MediaType.CHANNEL and self._client.tv_state.channels:
_LOGGER.debug("Searching channel")
LOGGER.debug("Searching channel")
partial_match_channel_id = None
perfect_match_channel_id = None
@@ -438,13 +398,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
partial_match_channel_id = channel["channelId"]
if perfect_match_channel_id is not None:
_LOGGER.debug(
LOGGER.debug(
"Switching to channel <%s> with perfect match",
perfect_match_channel_id,
)
await self._client.set_channel(perfect_match_channel_id)
elif partial_match_channel_id is not None:
_LOGGER.debug(
LOGGER.debug(
"Switching to channel <%s> with partial match",
partial_match_channel_id,
)
@@ -515,6 +475,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
content = await response.read()
if content is None:
_LOGGER.warning("Error retrieving proxied image from %s", url)
LOGGER.warning("Error retrieving proxied image from %s", url)
return content, None
+1 -1
View File
@@ -44,7 +44,7 @@ class LgWebOSNotificationService(BaseNotificationService):
@override
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the tv."""
client: WebOsClient = self._entry.runtime_data
client: WebOsClient = self._entry.runtime_data.client
data = kwargs[ATTR_DATA]
icon_path = data.get(ATTR_ICON) if data else None
@@ -47,7 +47,7 @@
},
"exceptions": {
"auth_failed": {
"message": "Pairing failed, make sure to accept the pairing request on your TV."
"message": "Pairing for {device} failed, make sure to accept the pairing request on your TV."
},
"communication_error": {
"message": "Communication error while calling {func} for device {name}: {error}"
@@ -61,6 +61,9 @@
"device_off": {
"message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
},
"device_unavailable": {
"message": "Device {device} is unavailable"
},
"invalid_entity_id": {
"message": "Entity {entity_id} is not a valid webOS TV entity."
},
+35 -3
View File
@@ -498,6 +498,30 @@ async def test_client_disconnected(
) -> None:
"""Test error not raised when client is disconnected."""
await setup_webostv(hass)
# Support turn on
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 }}",
},
},
},
],
},
)
client.is_connected.return_value = False
client.connect.side_effect = TimeoutError
@@ -521,6 +545,14 @@ async def test_client_key_update_on_connect(
assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key
# validate that the key is not updated if the client is already connected
client.is_connected.return_value = True
client.client_key = "old_key"
await mock_scan_interval(hass, freezer)
assert config_entry.data[CONF_CLIENT_SECRET] == "new_key"
@pytest.mark.parametrize(
("is_on", "exception", "error_message"),
@@ -916,7 +948,7 @@ async def test_availability(
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"
unavailable_log = f"Device {TV_NAME} is unavailable"
assert unavailable_log in caplog.text
# Clear logs and update the offline entity again - should NOT log again
@@ -930,7 +962,7 @@ async def test_availability(
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"
available_log = f"Fetching {TV_NAME} data recovered"
assert available_log in caplog.text
# Clear logs and make update again - should NOT log again
@@ -973,5 +1005,5 @@ async def test_availability(
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"
available_log = f"Fetching {TV_NAME} data recovered"
assert available_log in caplog.text