core/homeassistant/components/androidtv/media_player.py

600 lines
20 KiB
Python

"""Support for functionality to interact with Android TV / Fire TV devices."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime
import functools
import logging
from typing import Any, Concatenate, ParamSpec, TypeVar
from androidtv.constants import APPS, KEYS
from androidtv.exceptions import LockNotAcquiredException
import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_COMMAND,
ATTR_CONNECTIONS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_HOST,
CONF_NAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac
from .const import (
ANDROID_DEV,
ANDROID_DEV_OPT,
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_SCREENCAP,
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_EXCLUDE_UNNAMED_APPS,
DEFAULT_GET_SOURCES,
DEFAULT_SCREENCAP,
DEVICE_ANDROIDTV,
DOMAIN,
SIGNAL_CONFIG_ENTITY,
)
_ADBDeviceT = TypeVar("_ADBDeviceT", bound="ADBDevice")
_R = TypeVar("_R")
_P = ParamSpec("_P")
_LOGGER = logging.getLogger(__name__)
ATTR_ADB_RESPONSE = "adb_response"
ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path"
SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload"
PREFIX_ANDROIDTV = "Android TV"
PREFIX_FIRETV = "Fire TV"
# Translate from `AndroidTV` / `FireTV` reported state to HA state.
ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF,
"idle": MediaPlayerState.IDLE,
"standby": MediaPlayerState.STANDBY,
"playing": MediaPlayerState.PLAYING,
"paused": MediaPlayerState.PAUSED,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV entity."""
aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
device_class = aftv.DEVICE_CLASS
device_type = (
PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV
)
# CONF_NAME may be present in entry.data for configuration imported from YAML
device_name = entry.data.get(CONF_NAME) or f"{device_type} {entry.data[CONF_HOST]}"
device_args = [
aftv,
device_name,
device_type,
entry.unique_id,
entry.entry_id,
hass.data[DOMAIN][entry.entry_id],
]
async_add_entities(
[
AndroidTVDevice(*device_args)
if device_class == DEVICE_ANDROIDTV
else FireTVDevice(*device_args)
]
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ADB_COMMAND,
{vol.Required(ATTR_COMMAND): cv.string},
"adb_command",
)
platform.async_register_entity_service(
SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent"
)
platform.async_register_entity_service(
SERVICE_DOWNLOAD,
{
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
"service_download",
)
platform.async_register_entity_service(
SERVICE_UPLOAD,
{
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
"service_upload",
)
_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]]
_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]]
def adb_decorator(
override_available: bool = False,
) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]:
"""Wrap ADB methods and catch exceptions.
Allows for overriding the available status of the ADB connection via the
`override_available` parameter.
"""
def _adb_decorator(
func: _FuncType[_ADBDeviceT, _P, _R]
) -> _ReturnFuncType[_ADBDeviceT, _P, _R]:
"""Wrap the provided ADB method and catch exceptions."""
@functools.wraps(func)
async def _adb_exception_catcher(
self: _ADBDeviceT, *args: _P.args, **kwargs: _P.kwargs
) -> _R | None:
"""Call an ADB-related method and catch exceptions."""
if not self.available and not override_available:
return None
try:
return await func(self, *args, **kwargs)
except LockNotAcquiredException:
# If the ADB lock could not be acquired, skip this command
_LOGGER.info(
"ADB command not executed because the connection is currently"
" in use"
)
return None
except self.exceptions as err:
_LOGGER.error(
(
"Failed to execute an ADB command. ADB connection re-"
"establishing attempt in the next update. Error: %s"
),
err,
)
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
return None
except Exception:
# An unforeseen exception occurred. Close the ADB connection so that
# it doesn't happen over and over again, then raise the exception.
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
raise
return _adb_exception_catcher
return _adb_decorator
class ADBDevice(MediaPlayerEntity):
"""Representation of an Android TV or Fire TV device."""
_attr_device_class = MediaPlayerDeviceClass.TV
def __init__(
self,
aftv,
name,
dev_type,
unique_id,
entry_id,
entry_data,
):
"""Initialize the Android TV / Fire TV device."""
self.aftv = aftv
self._attr_name = name
self._attr_unique_id = unique_id
self._entry_id = entry_id
self._entry_data = entry_data
info = aftv.device_properties
model = info.get(ATTR_MODEL)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
model=f"{model} ({dev_type})" if model else dev_type,
name=name,
)
if manufacturer := info.get(ATTR_MANUFACTURER):
self._attr_device_info[ATTR_MANUFACTURER] = manufacturer
if sw_version := info.get(ATTR_SW_VERSION):
self._attr_device_info[ATTR_SW_VERSION] = sw_version
if mac := get_androidtv_mac(info):
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
self._app_id_to_name = {}
self._app_name_to_id = {}
self._get_sources = DEFAULT_GET_SOURCES
self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS
self._screencap = DEFAULT_SCREENCAP
self.turn_on_command = None
self.turn_off_command = None
# ADB exceptions to catch
if not aftv.adb_server_ip:
# Using "adb_shell" (Python ADB implementation)
self.exceptions = ADB_PYTHON_EXCEPTIONS
else:
# Using "pure-python-adb" (communicate with ADB server)
self.exceptions = ADB_TCP_EXCEPTIONS
# Property attributes
self._attr_extra_state_attributes = {
ATTR_ADB_RESPONSE: None,
ATTR_HDMI_INPUT: None,
}
# The number of consecutive failed connect attempts
self._failed_connect_count = 0
def _process_config(self):
"""Load the config options."""
_LOGGER.debug("Loading configuration options")
options = self._entry_data[ANDROID_DEV_OPT]
apps = options.get(CONF_APPS, {})
self._app_id_to_name = APPS.copy()
self._app_id_to_name.update(apps)
self._app_name_to_id = {
value: key for key, value in self._app_id_to_name.items() if value
}
# Make sure that apps overridden via the `apps` parameter are reflected
# in `self._app_name_to_id`
for key, value in apps.items():
self._app_name_to_id[value] = key
self._get_sources = options.get(CONF_GET_SOURCES, DEFAULT_GET_SOURCES)
self._exclude_unnamed_apps = options.get(
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
)
self._screencap = options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP)
self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND)
self.turn_on_command = options.get(CONF_TURN_ON_COMMAND)
async def async_added_to_hass(self) -> None:
"""Set config parameter when add to hass."""
await super().async_added_to_hass()
self._process_config()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_CONFIG_ENTITY}_{self._entry_id}",
self._process_config,
)
)
return
@property
def media_image_hash(self) -> str | None:
"""Hash value for media image."""
return f"{datetime.now().timestamp()}" if self._screencap else None
@adb_decorator()
async def _adb_screencap(self):
"""Take a screen capture from the device."""
return await self.aftv.adb_screencap()
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch current playing image."""
if (
not self._screencap
or self.state in {MediaPlayerState.OFF, None}
or not self.available
):
return None, None
media_data = await self._adb_screencap()
if media_data:
return media_data, "image/png"
# If an exception occurred and the device is no longer available, write the state
if not self.available:
self.async_write_ha_state()
return None, None
@adb_decorator()
async def async_media_play(self) -> None:
"""Send play command."""
await self.aftv.media_play()
@adb_decorator()
async def async_media_pause(self) -> None:
"""Send pause command."""
await self.aftv.media_pause()
@adb_decorator()
async def async_media_play_pause(self) -> None:
"""Send play/pause command."""
await self.aftv.media_play_pause()
@adb_decorator()
async def async_turn_on(self) -> None:
"""Turn on the device."""
if self.turn_on_command:
await self.aftv.adb_shell(self.turn_on_command)
else:
await self.aftv.turn_on()
@adb_decorator()
async def async_turn_off(self) -> None:
"""Turn off the device."""
if self.turn_off_command:
await self.aftv.adb_shell(self.turn_off_command)
else:
await self.aftv.turn_off()
@adb_decorator()
async def async_media_previous_track(self) -> None:
"""Send previous track command (results in rewind)."""
await self.aftv.media_previous_track()
@adb_decorator()
async def async_media_next_track(self) -> None:
"""Send next track command (results in fast-forward)."""
await self.aftv.media_next_track()
@adb_decorator()
async def async_select_source(self, source: str) -> None:
"""Select input source.
If the source starts with a '!', then it will close the app instead of
opening it.
"""
if isinstance(source, str):
if not source.startswith("!"):
await self.aftv.launch_app(self._app_name_to_id.get(source, source))
else:
source_ = source[1:].lstrip()
await self.aftv.stop_app(self._app_name_to_id.get(source_, source_))
@adb_decorator()
async def adb_command(self, command):
"""Send an ADB command to an Android TV / Fire TV device."""
if key := KEYS.get(command):
await self.aftv.adb_shell(f"input keyevent {key}")
return
if command == "GET_PROPERTIES":
self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str(
await self.aftv.get_properties_dict()
)
self.async_write_ha_state()
return
try:
response = await self.aftv.adb_shell(command)
except UnicodeDecodeError:
return
if isinstance(response, str) and response.strip():
self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = response.strip()
self.async_write_ha_state()
return
@adb_decorator()
async def learn_sendevent(self):
"""Translate a key press on a remote to ADB 'sendevent' commands."""
output = await self.aftv.learn_sendevent()
if output:
self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = output
self.async_write_ha_state()
msg = (
f"Output from service '{SERVICE_LEARN_SENDEVENT}' from"
f" {self.entity_id}: '{output}'"
)
persistent_notification.async_create(
self.hass,
msg,
title="Android TV",
)
_LOGGER.info("%s", msg)
@adb_decorator()
async def service_download(self, device_path, local_path):
"""Download a file from your Android TV / Fire TV device to your Home Assistant instance."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
await self.aftv.adb_pull(local_path, device_path)
@adb_decorator()
async def service_upload(self, device_path, local_path):
"""Upload a file from your Home Assistant instance to an Android TV / Fire TV device."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
await self.aftv.adb_push(local_path, device_path)
class AndroidTVDevice(ADBDevice):
"""Representation of an Android TV device."""
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
)
@adb_decorator(override_available=True)
async def async_update(self) -> None:
"""Update the device state and, if necessary, re-connect."""
# Check if device is disconnected.
if not self._attr_available:
# Try to connect
if await self.aftv.adb_connect(log_errors=self._failed_connect_count == 0):
self._failed_connect_count = 0
self._attr_available = True
else:
self._failed_connect_count += 1
# If the ADB connection is not intact, don't update.
if not self.available:
return
# Get the updated state and attributes.
(
state,
self._attr_app_id,
running_apps,
_,
self._attr_is_volume_muted,
self._attr_volume_level,
self._attr_extra_state_attributes[ATTR_HDMI_INPUT],
) = await self.aftv.update(self._get_sources)
self._attr_state = ANDROIDTV_STATES.get(state)
if self._attr_state is None:
self._attr_available = False
if running_apps:
self._attr_source = self._attr_app_name = self._app_id_to_name.get(
self._attr_app_id, self._attr_app_id
)
sources = [
self._app_id_to_name.get(
app_id, app_id if not self._exclude_unnamed_apps else None
)
for app_id in running_apps
]
self._attr_source_list = [source for source in sources if source]
else:
self._attr_source_list = None
@adb_decorator()
async def async_media_stop(self) -> None:
"""Send stop command."""
await self.aftv.media_stop()
@adb_decorator()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
is_muted = await self.aftv.is_volume_muted()
# `None` indicates that the muted status could not be determined
if is_muted is not None and is_muted != mute:
await self.aftv.mute_volume()
@adb_decorator()
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level."""
await self.aftv.set_volume_level(volume)
@adb_decorator()
async def async_volume_down(self) -> None:
"""Send volume down command."""
self._attr_volume_level = await self.aftv.volume_down(self._attr_volume_level)
@adb_decorator()
async def async_volume_up(self) -> None:
"""Send volume up command."""
self._attr_volume_level = await self.aftv.volume_up(self._attr_volume_level)
class FireTVDevice(ADBDevice):
"""Representation of a Fire TV device."""
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.STOP
)
@adb_decorator(override_available=True)
async def async_update(self) -> None:
"""Update the device state and, if necessary, re-connect."""
# Check if device is disconnected.
if not self._attr_available:
# Try to connect
if await self.aftv.adb_connect(log_errors=self._failed_connect_count == 0):
self._failed_connect_count = 0
self._attr_available = True
else:
self._failed_connect_count += 1
# If the ADB connection is not intact, don't update.
if not self.available:
return
# Get the `state`, `current_app`, `running_apps` and `hdmi_input`.
(
state,
self._attr_app_id,
running_apps,
self._attr_extra_state_attributes[ATTR_HDMI_INPUT],
) = await self.aftv.update(self._get_sources)
self._attr_state = ANDROIDTV_STATES.get(state)
if self._attr_state is None:
self._attr_available = False
if running_apps:
self._attr_source = self._app_id_to_name.get(
self._attr_app_id, self._attr_app_id
)
sources = [
self._app_id_to_name.get(
app_id, app_id if not self._exclude_unnamed_apps else None
)
for app_id in running_apps
]
self._attr_source_list = [source for source in sources if source]
else:
self._attr_source_list = None
@adb_decorator()
async def async_media_stop(self) -> None:
"""Send stop (back) command."""
await self.aftv.back()