Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add media_player platform to Android TV Remote #91677

Merged
merged 21 commits into from
May 6, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ build.json @home-assistant/supervisor
/tests/components/android_ip_webcam/ @engrbm87
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
/tests/components/androidtv/ @JeffLIrion @ollo69
/homeassistant/components/androidtv_remote/ @tronikos
/tests/components/androidtv_remote/ @tronikos
/homeassistant/components/androidtv_remote/ @tronikos @Drafteed
/tests/components/androidtv_remote/ @tronikos @Drafteed
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
/homeassistant/components/anova/ @Lash-L
/tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex
Expand Down
25 changes: 22 additions & 3 deletions homeassistant/components/androidtv_remote/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""The Android TV Remote integration."""
from __future__ import annotations

import logging

from androidtvremote2 import (
AndroidTVRemote,
CannotConnect,
Expand All @@ -9,20 +11,37 @@
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .const import DOMAIN
from .helpers import create_api

PLATFORMS: list[Platform] = [Platform.REMOTE]
_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV Remote from a config entry."""

api = create_api(hass, entry.data[CONF_HOST])

@callback
def is_available_updated(is_available: bool) -> None:
if is_available:
_LOGGER.info(
"Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST]
)
else:
_LOGGER.warning(
"Disconnected from %s at %s",
entry.data[CONF_NAME],
entry.data[CONF_HOST],
)

api.add_is_available_updated_callback(is_available_updated)

try:
await api.async_connect()
except InvalidAuth as exc:
Expand Down
84 changes: 84 additions & 0 deletions homeassistant/components/androidtv_remote/entity.py
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Base entity for Android TV Remote."""
from __future__ import annotations

from androidtvremote2 import AndroidTVRemote, ConnectionClosed

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo, Entity

from .const import DOMAIN


class AndroidTVRemoteBaseEntity(Entity):
"""Android TV Remote Base Entity."""

_attr_has_entity_name = True
_attr_should_poll = False

def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
self._api = api
self._host = config_entry.data[CONF_HOST]
self._name = config_entry.data[CONF_NAME]
self._attr_unique_id = config_entry.unique_id
self._attr_is_on = api.is_on
device_info = api.device_info
assert config_entry.unique_id
assert device_info
self._attr_device_info = DeviceInfo(
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
identifiers={(DOMAIN, config_entry.unique_id)},
name=self._name,
manufacturer=device_info["manufacturer"],
model=device_info["model"],
)

@callback
def _is_available_updated(self, is_available: bool) -> None:
"""Update the state when the device is ready to receive commands or is unavailable."""
self._attr_available = is_available
self.async_write_ha_state()

@callback
def _is_on_updated(self, is_on: bool) -> None:
"""Update the state when device turns on or off."""
self._attr_is_on = is_on
self.async_write_ha_state()

async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._api.add_is_available_updated_callback(self._is_available_updated)
self._api.add_is_on_updated_callback(self._is_on_updated)

async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
self._api.remove_is_available_updated_callback(self._is_available_updated)
self._api.remove_is_on_updated_callback(self._is_on_updated)

def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
"""Send a key press to Android TV.

This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc

def _send_launch_app_command(self, app_link: str) -> None:
"""Launch an app on Android TV.

This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc
4 changes: 2 additions & 2 deletions homeassistant/components/androidtv_remote/manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"domain": "androidtv_remote",
"name": "Android TV Remote",
"codeowners": ["@tronikos"],
"codeowners": ["@tronikos", "@Drafteed"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"quality_scale": "platinum",
"requirements": ["androidtvremote2==0.0.7"],
"requirements": ["androidtvremote2==0.0.8"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
217 changes: 217 additions & 0 deletions homeassistant/components/androidtv_remote/media_player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""Media player support for Android TV Remote."""
from __future__ import annotations

import asyncio
from typing import Any

from androidtvremote2 import AndroidTVRemote, ConnectionClosed

from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import DOMAIN
from .entity import AndroidTVRemoteBaseEntity

PARALLEL_UPDATES = 0


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android TV media player entity based on a config entry."""
api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AndroidTVRemoteMediaPlayerEntity(api, config_entry)])


class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEntity):
"""Android TV Remote Media Player Entity."""

_attr_assumed_state = True
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)

def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
super().__init__(api, config_entry)

self._volume_max: int | None = None
# These tasks are needed to create a job that sends a key press
# sequence that can be canceled if concurrency occurs
self._volume_set_task: asyncio.Task | None = None
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
self._channel_set_task: asyncio.Task | None = None

def _update_current_app(self, current_app: str) -> None:
"""Update current app info."""
self._attr_app_id = current_app
self._attr_app_name = current_app
Drafteed marked this conversation as resolved.
Show resolved Hide resolved

def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
"""Update volume info."""
if volume_info.get("max"):
self._volume_max = int(volume_info["max"])
self._attr_volume_level = int(volume_info["level"]) / self._volume_max
self._attr_is_volume_muted = bool(volume_info["muted"])
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET
else:
self._volume_max = None
self._attr_volume_level = None
self._attr_is_volume_muted = None
self._attr_supported_features &= ~MediaPlayerEntityFeature.VOLUME_SET
bdraco marked this conversation as resolved.
Show resolved Hide resolved

@callback
def _current_app_updated(self, current_app: str) -> None:
"""Update the state when the current app changes."""
self._update_current_app(current_app)
self.async_write_ha_state()

@callback
def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None:
"""Update the state when the volume info changes."""
self._update_volume_info(volume_info)
if not self._volume_set_task or self._volume_set_task.done():
self.async_write_ha_state()

async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()

self._update_current_app(self._api.current_app)
self._update_volume_info(self._api.volume_info)

self._api.add_current_app_updated_callback(self._current_app_updated)
self._api.add_volume_info_updated_callback(self._volume_info_updated)

async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
await super().async_will_remove_from_hass()

self._api.remove_current_app_updated_callback(self._current_app_updated)
self._api.remove_volume_info_updated_callback(self._volume_info_updated)

@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._attr_is_on:
return MediaPlayerState.ON
return MediaPlayerState.OFF

async def async_turn_on(self) -> None:
"""Turn the Android TV on."""
if not self._attr_is_on:
self._send_key_command("POWER")

async def async_turn_off(self) -> None:
"""Turn the Android TV off."""
if self._attr_is_on:
self._send_key_command("POWER")

async def async_volume_up(self) -> None:
"""Turn volume up for media player."""
self._send_key_command("VOLUME_UP")

async def async_volume_down(self) -> None:
"""Turn volume down for media player."""
self._send_key_command("VOLUME_DOWN")

async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
if mute != self.is_volume_muted:
self._send_key_command("VOLUME_MUTE")

async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
assert self._volume_max
Drafteed marked this conversation as resolved.
Show resolved Hide resolved
assert self._attr_volume_level is not None
if self._volume_set_task:
self._volume_set_task.cancel()
diff = volume - self._attr_volume_level
count = abs(round(diff * self._volume_max))
key_code = "VOLUME_UP" if diff > 0 else "VOLUME_DOWN"
self._volume_set_task = asyncio.create_task(
self._send_key_commands([key_code] * count)
)
await self._volume_set_task

async def async_media_play(self) -> None:
"""Send play command."""
self._send_key_command("MEDIA_PLAY")

async def async_media_pause(self) -> None:
"""Send pause command."""
self._send_key_command("MEDIA_PAUSE")

async def async_media_play_pause(self) -> None:
"""Send play/pause command."""
self._send_key_command("MEDIA_PLAY_PAUSE")

async def async_media_stop(self) -> None:
"""Send stop command."""
self._send_key_command("MEDIA_STOP")

async def async_media_previous_track(self) -> None:
"""Send previous track command."""
self._send_key_command("MEDIA_PREVIOUS")

async def async_media_next_track(self) -> None:
"""Send next track command."""
self._send_key_command("MEDIA_NEXT")

async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
if media_type == MediaType.CHANNEL:
if not media_id.isnumeric():
raise ValueError(f"Channel must be numeric: {media_id}")
if self._channel_set_task:
self._channel_set_task.cancel()
self._channel_set_task = asyncio.create_task(
self._send_key_commands(list(media_id))
)
await self._channel_set_task
return

if media_type == MediaType.URL:
self._send_launch_app_command(media_id)
return

raise ValueError(f"Invalid media type: {media_type}")

async def _send_key_commands(
self, key_codes: list[str], delay_secs: float = 0.1
) -> None:
"""Send a key press sequence to Android TV.

The delay is necessary because device may ignore
some commands if we send the sequence without delay.
"""
try:
for key_code in key_codes:
self._api.send_key_command(key_code)
await asyncio.sleep(delay_secs)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
) from exc
Loading