1 """Support for interface with an LG webOS Smart TV."""
3 from __future__
import annotations
6 from collections.abc
import Awaitable, Callable, Coroutine
7 from contextlib
import suppress
8 from datetime
import timedelta
9 from functools
import wraps
10 from http
import HTTPStatus
12 from typing
import Any, Concatenate, cast
14 from aiowebostv
import WebOsClient, WebOsTvPairError
16 from homeassistant
import util
18 MediaPlayerDeviceClass,
20 MediaPlayerEntityFeature,
27 ATTR_SUPPORTED_FEATURES,
40 from .
import update_client_key
50 from .triggers.turn_on
import async_get_turn_on_trigger
52 _LOGGER = logging.getLogger(__name__)
55 MediaPlayerEntityFeature.TURN_OFF
56 | MediaPlayerEntityFeature.NEXT_TRACK
57 | MediaPlayerEntityFeature.PAUSE
58 | MediaPlayerEntityFeature.PREVIOUS_TRACK
59 | MediaPlayerEntityFeature.SELECT_SOURCE
60 | MediaPlayerEntityFeature.PLAY_MEDIA
61 | MediaPlayerEntityFeature.PLAY
62 | MediaPlayerEntityFeature.STOP
65 SUPPORT_WEBOSTV_VOLUME = (
66 MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP
70 MIN_TIME_BETWEEN_FORCED_SCANS =
timedelta(seconds=1)
75 hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
77 """Set up the LG webOS Smart TV platform."""
78 client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id]
82 def cmd[_T: LgWebOSMediaPlayerEntity, **_P](
83 func: Callable[Concatenate[_T, _P], Awaitable[
None]],
84 ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any,
None]]:
85 """Catch command exceptions."""
88 async
def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) ->
None:
89 """Wrap all command methods."""
91 await func(self, *args, **kwargs)
92 except WEBOSTV_EXCEPTIONS
as exc:
93 if self.state != MediaPlayerState.OFF:
95 f
"Error calling {func.__name__} on entity {self.entity_id},"
96 f
" state:{self.state}"
99 "Error calling %s on entity %s, state:%s, error: %r",
110 """Representation of a LG webOS Smart TV."""
112 _attr_device_class = MediaPlayerDeviceClass.TV
113 _attr_has_entity_name =
True
116 def __init__(self, entry: ConfigEntry, client: WebOsClient) ->
None:
117 """Initialize the webos device."""
123 self.
_sources_sources = entry.options.get(CONF_SOURCES)
135 """Connect and subscribe to dispatcher signals and state updates."""
138 if (entry := self.
registry_entryregistry_entry)
and entry.device_id:
149 await self.
_client_client.register_state_update_callback(
158 state.attributes.get(
161 & ~MediaPlayerEntityFeature.TURN_ON
165 """Call disconnect on removal."""
169 """Handle domain-specific signal by calling appropriate method."""
170 if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE:
173 if entity_ids == ENTITY_MATCH_ALL
or self.
entity_identity_id
in entity_ids:
176 for key, value
in data.items()
177 if key
not in [
"entity_id",
"method"]
179 await getattr(self, data[
"method"])(**params)
182 """Update state from WebOsClient."""
187 """Update entity state attributes."""
191 MediaPlayerState.ON
if self.
_client_client.is_on
else MediaPlayerState.OFF
196 if self.
_client_client.volume
is not None:
203 if self.
_client_client.current_app_id == LIVE_TV_APP_ID:
207 if (self.
_client_client.current_app_id == LIVE_TV_APP_ID)
and (
208 self.
_client_client.current_channel
is not None
211 str, self.
_client_client.current_channel.get(
"channelName")
215 if self.
_client_client.current_app_id
in self.
_client_client.apps:
216 icon: str = self.
_client_client.apps[self.
_client_client.current_app_id][
"largeIcon"]
217 if not icon.startswith(
"http"):
218 icon = self.
_client_client.apps[self.
_client_client.current_app_id][
"icon"]
222 supported = SUPPORT_WEBOSTV
223 if self.
_client_client.sound_output
in (
"external_arc",
"external_speaker"):
224 supported = supported | SUPPORT_WEBOSTV_VOLUME
225 elif self.
_client_client.sound_output !=
"lineout":
228 | SUPPORT_WEBOSTV_VOLUME
229 | MediaPlayerEntityFeature.VOLUME_SET
235 identifiers={(DOMAIN, cast(str, self.
unique_idunique_id))},
243 and self.
_client_client.media_state
is not None
244 and self.
_client_client.media_state.get(
"foregroundAppInfo")
is not None
247 for entry
in self.
_client_client.media_state.get(
"foregroundAppInfo"):
248 if entry.get(
"playState") ==
"playing":
249 self.
_attr_state_attr_state = MediaPlayerState.PLAYING
250 elif entry.get(
"playState") ==
"paused":
251 self.
_attr_state_attr_state = MediaPlayerState.PAUSED
252 elif entry.get(
"playState") ==
"unloaded":
253 self.
_attr_state_attr_state = MediaPlayerState.IDLE
256 maj_v = self.
_client_client.software_info.get(
"major_ver")
257 min_v = self.
_client_client.software_info.get(
"minor_ver")
261 if model := self.
_client_client.system_info.get(
"modelName"):
267 ATTR_SOUND_OUTPUT: self.
_client_client.sound_output
271 """Update list of sources from current source, apps, inputs and configured list."""
274 conf_sources = self.
_sources_sources
276 found_live_tv =
False
277 for app
in self.
_client_client.apps.values():
278 if app[
"id"] == LIVE_TV_APP_ID:
280 if app[
"id"] == self.
_client_client.current_app_id:
285 or app[
"id"]
in conf_sources
286 or any(word
in app[
"title"]
for word
in conf_sources)
287 or any(word
in app[
"id"]
for word
in conf_sources)
291 for source
in self.
_client_client.inputs.values():
292 if source[
"appId"] == LIVE_TV_APP_ID:
294 if source[
"appId"] == self.
_client_client.current_app_id:
299 or source[
"label"]
in conf_sources
300 or any(source[
"label"].find(word) != -1
for word
in conf_sources)
309 elif not found_live_tv:
310 app = {
"id": LIVE_TV_APP_ID,
"title":
"Live TV"}
311 if self.
_client_client.current_app_id == LIVE_TV_APP_ID:
316 or app[
"id"]
in conf_sources
317 or any(word
in app[
"title"]
for word
in conf_sources)
318 or any(word
in app[
"id"]
for word
in conf_sources)
322 @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
328 with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError):
330 await self.
_client_client.connect()
331 except WebOsTvPairError:
332 self.
_entry_entry.async_start_reauth(self.
hasshass)
338 """Flag media player features that are supported."""
346 """Turn off media player."""
347 await self.
_client_client.power_off()
350 """Turn on media player."""
355 """Volume up the media player."""
356 await self.
_client_client.volume_up()
360 """Volume down media player."""
361 await self.
_client_client.volume_down()
365 """Set volume level, range 0..1."""
366 tv_volume =
int(round(volume * 100))
367 await self.
_client_client.set_volume(tv_volume)
371 """Send mute command."""
372 await self.
_client_client.set_mute(mute)
376 """Select the sound output."""
377 await self.
_client_client.change_sound_output(sound_output)
381 """Simulate play pause media player."""
389 """Select input source."""
395 if source_dict.get(
"title"):
396 await self.
_client_client.launch_app(source_dict[
"id"])
397 elif source_dict.get(
"label"):
398 await self.
_client_client.set_input(source_dict[
"id"])
402 self, media_type: MediaType | str, media_id: str, **kwargs: Any
404 """Play a piece of media."""
405 _LOGGER.debug(
"Call play media type <%s>, Id <%s>", media_type, media_id)
407 if media_type == MediaType.CHANNEL:
408 _LOGGER.debug(
"Searching channel")
409 partial_match_channel_id =
None
410 perfect_match_channel_id =
None
412 for channel
in self.
_client_client.channels:
413 if media_id == channel[
"channelNumber"]:
414 perfect_match_channel_id = channel[
"channelId"]
417 if media_id.lower() == channel[
"channelName"].lower():
418 perfect_match_channel_id = channel[
"channelId"]
421 if media_id.lower()
in channel[
"channelName"].lower():
422 partial_match_channel_id = channel[
"channelId"]
424 if perfect_match_channel_id
is not None:
426 "Switching to channel <%s> with perfect match",
427 perfect_match_channel_id,
429 await self.
_client_client.set_channel(perfect_match_channel_id)
430 elif partial_match_channel_id
is not None:
432 "Switching to channel <%s> with partial match",
433 partial_match_channel_id,
435 await self.
_client_client.set_channel(partial_match_channel_id)
439 """Send play command."""
441 await self.
_client_client.play()
445 """Send media pause command to media player."""
447 await self.
_client_client.pause()
451 """Send stop command to media player."""
452 await self.
_client_client.stop()
456 """Send next track command."""
457 if self.
_client_client.current_app_id == LIVE_TV_APP_ID:
458 await self.
_client_client.channel_up()
460 await self.
_client_client.fast_forward()
464 """Send the previous track command."""
465 if self.
_client_client.current_app_id == LIVE_TV_APP_ID:
466 await self.
_client_client.channel_down()
468 await self.
_client_client.rewind()
472 """Send a button press."""
473 await self.
_client_client.button(button)
477 """Send a command."""
478 await self.
_client_client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
481 """Retrieve an image.
483 webOS uses self-signed certificates, thus we need to use an empty
484 SSLContext to bypass validation errors if url starts with https.
489 with suppress(TimeoutError):
490 async
with asyncio.timeout(10):
491 response = await websession.get(url, ssl=
False)
492 if response.status == HTTPStatus.OK:
493 content = await response.read()
496 _LOGGER.warning(
"Error retrieving proxied image from %s", url)
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
str|None _friendly_name_internal(self)
State|None async_get_last_state(self)
None async_register(HomeAssistant hass, system_health.SystemHealthRegistration register)
web.Response get(self, web.Request request, str config_key)
dict[str, str] async_get_turn_on_trigger(str device_id)
None update_client_key(HomeAssistant hass, ConfigEntry entry, WebOsClient client)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
def async_run(config_dir)