mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add Coordinator to LG WebOS TV (#177627)
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user