Files
core/homeassistant/components/webostv/media_player.py
T

438 lines
15 KiB
Python

"""Support for interface with an LG webOS TV."""
import asyncio
from contextlib import suppress
from http import HTTPStatus
from typing import Any, cast, override
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.const import EntityStateAttribute
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
ATTR_PAYLOAD,
ATTR_SOUND_OUTPUT,
CONF_SOURCES,
DOMAIN,
LIVE_TV_APP_ID,
LOGGER,
)
from .coordinator import WebOsTvConfigEntry
from .entity import WebOsTvEntity, cmd
from .triggers.turn_on import async_get_turn_on_trigger
SUPPORT_WEBOSTV = (
MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
)
SUPPORT_WEBOSTV_VOLUME = (
MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP
)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: WebOsTvConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the LG webOS TV platform."""
async_add_entities([LgWebOSMediaPlayerEntity(entry)])
class LgWebOSMediaPlayerEntity(WebOsTvEntity, RestoreEntity, MediaPlayerEntity):
"""Representation of a LG webOS TV."""
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_name = None
def __init__(self, entry: WebOsTvConfigEntry) -> None:
"""Initialize the webos device."""
super().__init__(entry)
self._attr_assumed_state = True
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._current_source = None
self._source_list: dict = {}
self._supported_features = MediaPlayerEntityFeature(0)
self._update_states()
@override
async def async_added_to_hass(self) -> None:
"""Connect and subscribe to dispatcher signals and state updates."""
await super().async_added_to_hass()
if (entry := self.registry_entry) and entry.device_id:
self.async_on_remove(
self.coordinator.turn_on.async_register(
self.hass, async_get_turn_on_trigger(entry.device_id)
)
)
if (
self.state == MediaPlayerState.OFF
and (state := await self.async_get_last_state()) is not None
):
self._supported_features = (
state.attributes.get(
EntityStateAttribute.SUPPORTED_FEATURES,
MediaPlayerEntityFeature(0),
)
& ~MediaPlayerEntityFeature.TURN_ON
)
self.async_on_remove(self.coordinator.async_add_listener(self._update_callback))
@callback
def _update_callback(self) -> None:
"""Update state from WebOsClient."""
self._update_states()
self.async_write_ha_state()
def _update_states(self) -> None:
"""Update entity state attributes."""
tv_state = self._client.tv_state
self._attr_extra_state_attributes = {}
if tv_state.is_on or not self._supported_features:
supported = SUPPORT_WEBOSTV
if tv_state.sound_output == "external_speaker":
supported = supported | SUPPORT_WEBOSTV_VOLUME
elif tv_state.sound_output != "lineout":
supported = (
supported
| SUPPORT_WEBOSTV_VOLUME
| MediaPlayerEntityFeature.VOLUME_SET
)
self._supported_features = supported
if not tv_state.is_on:
self._attr_state = MediaPlayerState.OFF
self._attr_assumed_state = False
return
self._attr_state = MediaPlayerState.ON
self._update_sources()
self._attr_is_volume_muted = cast(bool, tv_state.muted)
self._attr_volume_level = None
if tv_state.volume is not None:
self._attr_volume_level = tv_state.volume / 100.0
self._attr_source = self._current_source
self._attr_source_list = sorted(self._source_list)
self._attr_media_content_type = None
if tv_state.current_app_id == LIVE_TV_APP_ID:
self._attr_media_content_type = MediaType.CHANNEL
self._attr_media_title = None
if (tv_state.current_app_id == LIVE_TV_APP_ID) and (
tv_state.current_channel is not None
):
self._attr_media_title = cast(
str, tv_state.current_channel.get("channelName")
)
self._attr_media_image_url = None
if tv_state.current_app_id in tv_state.apps:
icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"]
if not icon.startswith("http"):
icon = tv_state.apps[tv_state.current_app_id]["icon"]
self._attr_media_image_url = icon
self._attr_assumed_state = True
if tv_state.media_state:
self._attr_assumed_state = False
for entry in tv_state.media_state:
if entry.get("playState") == "playing":
self._attr_state = MediaPlayerState.PLAYING
elif entry.get("playState") == "paused":
self._attr_state = MediaPlayerState.PAUSED
elif entry.get("playState") == "unloaded":
self._attr_state = MediaPlayerState.IDLE
tv_info = self._client.tv_info
maj_v = tv_info.software.get("major_ver")
min_v = tv_info.software.get("minor_ver")
if maj_v and min_v:
self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}"
if model := tv_info.system.get("modelName"):
self._attr_device_info["model"] = model
if serial_number := tv_info.system.get("serialNumber"):
self._attr_device_info["serial_number"] = serial_number
if tv_state.sound_output is not None:
self._attr_extra_state_attributes = {
ATTR_SOUND_OUTPUT: tv_state.sound_output
}
def _update_sources(self) -> None:
"""Update list of sources from current source and apps."""
tv_state = self._client.tv_state
source_list = self._source_list
self._source_list = {}
conf_sources = self._sources
found_live_tv = False
for app in tv_state.apps.values():
if app["id"] == LIVE_TV_APP_ID:
found_live_tv = True
if app["id"] == tv_state.current_app_id:
self._current_source = app["title"]
self._source_list[app["title"]] = app
elif (
not conf_sources
or app["id"] in conf_sources
or any(word in app["title"] for word in conf_sources)
or any(word in app["id"] for word in conf_sources)
):
self._source_list[app["title"]] = app
for source in tv_state.inputs.values():
if source["appId"] == LIVE_TV_APP_ID:
found_live_tv = True
if source["appId"] == tv_state.current_app_id:
self._current_source = source["label"]
self._source_list[source["label"]] = source
elif (
not conf_sources
or source["label"] in conf_sources
or any(source["label"].find(word) != -1 for word in conf_sources)
):
self._source_list[source["label"]] = source
# empty list, TV may be off, keep previous list
if not self._source_list and source_list:
self._source_list = source_list
# special handling of live tv since this might
# not appear in the app or input lists in some cases
elif not found_live_tv:
app = {"id": LIVE_TV_APP_ID, "title": "Live TV"}
if tv_state.current_app_id == LIVE_TV_APP_ID:
self._current_source = app["title"]
self._source_list["Live TV"] = app
elif (
not conf_sources
or app["id"] in conf_sources
or any(word in app["title"] for word in conf_sources)
or any(word in app["id"] for word in conf_sources)
):
self._source_list["Live TV"] = app
@property
@override
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
if self.coordinator.turn_on:
return self._supported_features | MediaPlayerEntityFeature.TURN_ON
return self._supported_features
@cmd
@override
async def async_turn_off(self) -> None:
"""Turn off media player."""
await self._client.power_off()
@override
async def async_turn_on(self) -> None:
"""Turn on media player."""
await self.coordinator.turn_on.async_run(self.hass, self._context)
@cmd
@override
async def async_volume_up(self) -> None:
"""Volume up the media player."""
await self._client.volume_up()
@cmd
@override
async def async_volume_down(self) -> None:
"""Volume down media player."""
await self._client.volume_down()
@cmd
@override
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
tv_volume = round(volume * 100)
await self._client.set_volume(tv_volume)
@cmd
@override
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._client.set_mute(mute)
@cmd
async def async_select_sound_output(self, sound_output: str) -> ServiceResponse:
"""Select the sound output."""
return await self._client.change_sound_output(sound_output)
@cmd
@override
async def async_media_play_pause(self) -> None:
"""Simulate play pause media player."""
if self._paused:
await self.async_media_play()
else:
await self.async_media_pause()
@cmd
@override
async def async_select_source(self, source: str) -> None:
"""Select input source."""
if (source_dict := self._source_list.get(source)) is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="source_not_found",
translation_placeholders={
"source": source,
"name": self.entity_id,
},
)
if source_dict.get("title"):
await self._client.launch_app(source_dict["id"])
elif source_dict.get("label"):
await self._client.set_input(source_dict["id"])
@cmd
@override
async def async_play_media(
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)
if media_type == MediaType.CHANNEL and self._client.tv_state.channels:
LOGGER.debug("Searching channel")
partial_match_channel_id = None
perfect_match_channel_id = None
for channel in self._client.tv_state.channels:
if media_id.lower() == channel["channelName"].lower():
perfect_match_channel_id = channel["channelId"]
break
if (
media_id == channel["channelNumber"]
and perfect_match_channel_id is None
):
perfect_match_channel_id = channel["channelId"]
continue
if media_id.lower() in channel["channelName"].lower():
partial_match_channel_id = channel["channelId"]
if perfect_match_channel_id is not None:
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(
"Switching to channel <%s> with partial match",
partial_match_channel_id,
)
await self._client.set_channel(partial_match_channel_id)
@cmd
@override
async def async_media_play(self) -> None:
"""Send play command."""
self._paused = False
await self._client.play()
@cmd
@override
async def async_media_pause(self) -> None:
"""Send media pause command to media player."""
self._paused = True
await self._client.pause()
@cmd
@override
async def async_media_stop(self) -> None:
"""Send stop command to media player."""
await self._client.stop()
@cmd
@override
async def async_media_next_track(self) -> None:
"""Send next track command."""
if self._client.tv_state.current_app_id == LIVE_TV_APP_ID:
await self._client.channel_up()
else:
await self._client.fast_forward()
@cmd
@override
async def async_media_previous_track(self) -> None:
"""Send the previous track command."""
if self._client.tv_state.current_app_id == LIVE_TV_APP_ID:
await self._client.channel_down()
else:
await self._client.rewind()
@cmd
async def async_button(self, button: str) -> None:
"""Send a button press."""
await self._client.button(button)
@cmd
async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse:
"""Send a command."""
return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD))
@override
async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]:
"""Retrieve an image.
webOS uses self-signed certificates, thus we need to use an empty
SSLContext to bypass validation errors if url starts with https.
"""
content = None
websession = async_get_clientsession(self.hass)
with suppress(TimeoutError):
async with asyncio.timeout(10):
response = await websession.get(url, ssl=False)
if response.status == HTTPStatus.OK:
content = await response.read()
if content is None:
LOGGER.warning("Error retrieving proxied image from %s", url)
return content, None