Add media_player platform to Android TV Remote (#91677)

This commit is contained in:
Artem Draft 2023-05-06 17:18:34 +03:00 committed by GitHub
parent 053eaad2bd
commit d816da9355
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 702 additions and 131 deletions

View File

@ -80,8 +80,8 @@ build.json @home-assistant/supervisor
/tests/components/android_ip_webcam/ @engrbm87 /tests/components/android_ip_webcam/ @engrbm87
/homeassistant/components/androidtv/ @JeffLIrion @ollo69 /homeassistant/components/androidtv/ @JeffLIrion @ollo69
/tests/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69
/homeassistant/components/androidtv_remote/ @tronikos /homeassistant/components/androidtv_remote/ @tronikos @Drafteed
/tests/components/androidtv_remote/ @tronikos /tests/components/androidtv_remote/ @tronikos @Drafteed
/homeassistant/components/anova/ @Lash-L /homeassistant/components/anova/ @Lash-L
/tests/components/anova/ @Lash-L /tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex /homeassistant/components/anthemav/ @hyralex

View File

@ -1,6 +1,8 @@
"""The Android TV Remote integration.""" """The Android TV Remote integration."""
from __future__ import annotations from __future__ import annotations
import logging
from androidtvremote2 import ( from androidtvremote2 import (
AndroidTVRemote, AndroidTVRemote,
CannotConnect, CannotConnect,
@ -9,20 +11,37 @@ from androidtvremote2 import (
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN from .const import DOMAIN
from .helpers import create_api 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android TV Remote from a config entry.""" """Set up Android TV Remote from a config entry."""
api = create_api(hass, entry.data[CONF_HOST]) 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: try:
await api.async_connect() await api.async_connect()
except InvalidAuth as exc: except InvalidAuth as exc:

View File

@ -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(
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

View File

@ -1,13 +1,13 @@
{ {
"domain": "androidtv_remote", "domain": "androidtv_remote",
"name": "Android TV Remote", "name": "Android TV Remote",
"codeowners": ["@tronikos"], "codeowners": ["@tronikos", "@Drafteed"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote", "documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["androidtvremote2"], "loggers": ["androidtvremote2"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["androidtvremote2==0.0.7"], "requirements": ["androidtvremote2==0.0.8"],
"zeroconf": ["_androidtvremote2._tcp.local."] "zeroconf": ["_androidtvremote2._tcp.local."]
} }

View File

@ -0,0 +1,198 @@
"""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
_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)
# This task is needed to create a job that sends a key press
# sequence that can be canceled if concurrency occurs
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
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
"""Update volume info."""
if volume_info.get("max"):
self._attr_volume_level = int(volume_info["level"]) / int(
volume_info["max"]
)
self._attr_is_volume_muted = bool(volume_info["muted"])
else:
self._attr_volume_level = None
self._attr_is_volume_muted = None
@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)
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_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

View File

@ -3,10 +3,9 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable from collections.abc import Iterable
import logging
from typing import Any from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed from androidtvremote2 import AndroidTVRemote
from homeassistant.components.remote import ( from homeassistant.components.remote import (
ATTR_ACTIVITY, ATTR_ACTIVITY,
@ -20,17 +19,13 @@ from homeassistant.components.remote import (
RemoteEntityFeature, RemoteEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
@ -43,62 +38,29 @@ async def async_setup_entry(
async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) async_add_entities([AndroidTVRemoteEntity(api, config_entry)])
class AndroidTVRemoteEntity(RemoteEntity): class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
"""Representation of an Android TV Remote.""" """Android TV Remote Entity."""
_attr_has_entity_name = True _attr_supported_features = RemoteEntityFeature.ACTIVITY
_attr_should_poll = False
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
"""Initialize device."""
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_supported_features = RemoteEntityFeature.ACTIVITY
self._attr_is_on = api.is_on
self._attr_current_activity = api.current_app
device_info = api.device_info
assert config_entry.unique_id
assert device_info
self._attr_device_info = DeviceInfo(
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 @callback
def is_on_updated(is_on: bool) -> None: def _current_app_updated(self, current_app: str) -> None:
self._attr_is_on = is_on """Update the state when the current app changes."""
self.async_write_ha_state()
@callback
def current_app_updated(current_app: str) -> None:
self._attr_current_activity = current_app self._attr_current_activity = current_app
self.async_write_ha_state() self.async_write_ha_state()
@callback async def async_added_to_hass(self) -> None:
def is_available_updated(is_available: bool) -> None: """Register callbacks."""
if is_available: await super().async_added_to_hass()
_LOGGER.info(
"Reconnected to %s at %s",
self._name,
self._host,
)
else:
_LOGGER.warning(
"Disconnected from %s at %s",
self._name,
self._host,
)
self._attr_available = is_available
self.async_write_ha_state()
api.add_is_on_updated_callback(is_on_updated) self._attr_current_activity = self._api.current_app
api.add_current_app_updated_callback(current_app_updated) self._api.add_current_app_updated_callback(self._current_app_updated)
api.add_is_available_updated_callback(is_available_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)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the Android TV on.""" """Turn the Android TV on."""
@ -128,27 +90,3 @@ class AndroidTVRemoteEntity(RemoteEntity):
else: else:
self._send_key_command(single_command, "SHORT") self._send_key_command(single_command, "SHORT")
await asyncio.sleep(delay_secs) await asyncio.sleep(delay_secs)
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

View File

@ -333,7 +333,7 @@ amcrest==1.9.7
androidtv[async]==0.0.70 androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote # homeassistant.components.androidtv_remote
androidtvremote2==0.0.7 androidtvremote2==0.0.8
# homeassistant.components.anel_pwrctrl # homeassistant.components.anel_pwrctrl
anel_pwrctrl-homeassistant==0.0.1.dev2 anel_pwrctrl-homeassistant==0.0.1.dev2

View File

@ -308,7 +308,7 @@ ambiclimate==0.2.1
androidtv[async]==0.0.70 androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote # homeassistant.components.androidtv_remote
androidtvremote2==0.0.7 androidtvremote2==0.0.8
# homeassistant.components.anova # homeassistant.components.anova
anova-wifi==0.9.0 anova-wifi==0.9.0

View File

@ -1,5 +1,5 @@
"""Fixtures for the Android TV Remote integration tests.""" """Fixtures for the Android TV Remote integration tests."""
from collections.abc import Generator from collections.abc import Callable, Generator
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -42,6 +42,57 @@ def mock_api() -> Generator[None, MagicMock, None]:
"manufacturer": "My Android TV manufacturer", "manufacturer": "My Android TV manufacturer",
"model": "My Android TV model", "model": "My Android TV model",
} }
is_on_updated_callbacks: list[Callable] = []
current_app_updated_callbacks: list[Callable] = []
volume_info_updated_callbacks: list[Callable] = []
is_available_updated_callbacks: list[Callable] = []
def mocked_add_is_on_updated_callback(callback: Callable):
is_on_updated_callbacks.append(callback)
def mocked_add_current_app_updated_callback(callback: Callable):
current_app_updated_callbacks.append(callback)
def mocked_add_volume_info_updated_callback(callback: Callable):
volume_info_updated_callbacks.append(callback)
def mocked_add_is_available_updated_callbacks(callback: Callable):
is_available_updated_callbacks.append(callback)
def mocked_is_on_updated(is_on: bool):
for callback in is_on_updated_callbacks:
callback(is_on)
def mocked_current_app_updated(current_app: str):
for callback in current_app_updated_callbacks:
callback(current_app)
def mocked_volume_info_updated(volume_info: dict[str, str | bool]):
for callback in volume_info_updated_callbacks:
callback(volume_info)
def mocked_is_available_updated(is_available: bool):
for callback in is_available_updated_callbacks:
callback(is_available)
mock_api.add_is_on_updated_callback.side_effect = (
mocked_add_is_on_updated_callback
)
mock_api.add_current_app_updated_callback.side_effect = (
mocked_add_current_app_updated_callback
)
mock_api.add_volume_info_updated_callback.side_effect = (
mocked_add_volume_info_updated_callback
)
mock_api.add_is_available_updated_callback.side_effect = (
mocked_add_is_available_updated_callbacks
)
mock_api._on_is_on_updated.side_effect = mocked_is_on_updated
mock_api._on_current_app_updated.side_effect = mocked_current_app_updated
mock_api._on_volume_info_updated.side_effect = mocked_volume_info_updated
mock_api._on_is_available_updated.side_effect = mocked_is_available_updated
yield mock_api yield mock_api

View File

@ -0,0 +1,314 @@
"""Tests for the Android TV Remote remote platform."""
from unittest.mock import MagicMock, call
from androidtvremote2 import ConnectionClosed
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
MEDIA_PLAYER_ENTITY = "media_player.my_android_tv"
async def test_media_player_receives_push_updates(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote media player receives push updates and state is updated."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_api._on_is_on_updated(False)
assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_OFF)
mock_api._on_is_on_updated(True)
assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON)
mock_api._on_current_app_updated("com.google.android.tvlauncher")
assert (
hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_id")
== "com.google.android.tvlauncher"
)
assert (
hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_name")
== "com.google.android.tvlauncher"
)
mock_api._on_volume_info_updated({"level": 35, "muted": False, "max": 100})
assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.35
mock_api._on_volume_info_updated({"level": 50, "muted": True, "max": 100})
assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.50
assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted")
mock_api._on_volume_info_updated({"level": 0, "muted": False, "max": 0})
assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") is None
assert (
hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted") is None
)
mock_api._on_is_available_updated(False)
assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_UNAVAILABLE)
mock_api._on_is_available_updated(True)
assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON)
async def test_media_player_toggles(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote media player toggles."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.services.async_call(
"media_player",
"turn_off",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api._on_is_on_updated(False)
mock_api.send_key_command.assert_called_with("POWER", "SHORT")
assert await hass.services.async_call(
"media_player",
"turn_on",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api._on_is_on_updated(True)
mock_api.send_key_command.assert_called_with("POWER", "SHORT")
async def test_media_player_volume(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote media player up/down/mute volume."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.services.async_call(
"media_player",
"volume_up",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api._on_volume_info_updated({"level": 10, "muted": False, "max": 100})
mock_api.send_key_command.assert_called_with("VOLUME_UP", "SHORT")
assert await hass.services.async_call(
"media_player",
"volume_down",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100})
mock_api.send_key_command.assert_called_with("VOLUME_DOWN", "SHORT")
assert await hass.services.async_call(
"media_player",
"volume_mute",
{"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": True},
blocking=True,
)
mock_api._on_volume_info_updated({"level": 9, "muted": True, "max": 100})
mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT")
assert await hass.services.async_call(
"media_player",
"volume_mute",
{"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": False},
blocking=True,
)
mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100})
mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT")
async def test_media_player_controls(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote media player play/pause/stop/next/prev."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.services.async_call(
"media_player",
"media_play",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_PLAY", "SHORT")
assert await hass.services.async_call(
"media_player",
"media_pause",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_PAUSE", "SHORT")
assert await hass.services.async_call(
"media_player",
"media_play_pause",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_PLAY_PAUSE", "SHORT")
assert await hass.services.async_call(
"media_player",
"media_stop",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_STOP", "SHORT")
assert await hass.services.async_call(
"media_player",
"media_previous_track",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_PREVIOUS", "SHORT")
assert await hass.services.async_call(
"media_player",
"media_next_track",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_key_command.assert_called_with("MEDIA_NEXT", "SHORT")
async def test_media_player_play_media(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote media player play_media."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "channel",
"media_content_id": "45",
},
blocking=True,
)
assert mock_api.send_key_command.mock_calls == [
call("4"),
call("5"),
]
# Test that set channel task has been canceled
mock_api.send_key_command.reset_mock()
await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "channel",
"media_content_id": "7777",
},
blocking=False,
)
await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "channel",
"media_content_id": "11",
},
blocking=True,
)
assert mock_api.send_key_command.call_count == 2
assert await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "url",
"media_content_id": "https://www.youtube.com",
},
blocking=True,
)
mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com")
with pytest.raises(ValueError):
assert await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "channel",
"media_content_id": "abc",
},
blocking=True,
)
with pytest.raises(ValueError):
assert await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "music",
"media_content_id": "invalid",
},
blocking=True,
)
async def test_media_player_connection_closed(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test media_player raise HomeAssistantError if ConnectionClosed."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_api.send_key_command.side_effect = ConnectionClosed()
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"media_player",
"media_pause",
{"entity_id": MEDIA_PLAYER_ENTITY},
blocking=True,
)
mock_api.send_launch_app_command.side_effect = ConnectionClosed()
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "channel",
"media_content_id": "1",
},
blocking=True,
)

View File

@ -1,5 +1,4 @@
"""Tests for the Android TV Remote remote platform.""" """Tests for the Android TV Remote remote platform."""
from collections.abc import Callable
from unittest.mock import MagicMock, call from unittest.mock import MagicMock, call
from androidtvremote2 import ConnectionClosed from androidtvremote2 import ConnectionClosed
@ -19,49 +18,25 @@ async def test_remote_receives_push_updates(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None: ) -> None:
"""Test the Android TV Remote receives push updates and state is updated.""" """Test the Android TV Remote receives push updates and state is updated."""
is_on_updated_callback: Callable | None = None
current_app_updated_callback: Callable | None = None
is_available_updated_callback: Callable | None = None
def mocked_add_is_on_updated_callback(callback: Callable):
nonlocal is_on_updated_callback
is_on_updated_callback = callback
def mocked_add_current_app_updated_callback(callback: Callable):
nonlocal current_app_updated_callback
current_app_updated_callback = callback
def mocked_add_is_available_updated_callback(callback: Callable):
nonlocal is_available_updated_callback
is_available_updated_callback = callback
mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback
mock_api.add_current_app_updated_callback.side_effect = (
mocked_add_current_app_updated_callback
)
mock_api.add_is_available_updated_callback.side_effect = (
mocked_add_is_available_updated_callback
)
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED
is_on_updated_callback(False) mock_api._on_is_on_updated(False)
assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF)
is_on_updated_callback(True) mock_api._on_is_on_updated(True)
assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON)
current_app_updated_callback("activity1") mock_api._on_current_app_updated("activity1")
assert ( assert (
hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1"
) )
is_available_updated_callback(False) mock_api._on_is_available_updated(False)
assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE)
is_available_updated_callback(True) mock_api._on_is_available_updated(True)
assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON)
@ -69,14 +44,6 @@ async def test_remote_toggles(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None: ) -> None:
"""Test the Android TV Remote toggles.""" """Test the Android TV Remote toggles."""
is_on_updated_callback: Callable | None = None
def mocked_add_is_on_updated_callback(callback: Callable):
nonlocal is_on_updated_callback
is_on_updated_callback = callback
mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED
@ -87,7 +54,7 @@ async def test_remote_toggles(
{"entity_id": REMOTE_ENTITY}, {"entity_id": REMOTE_ENTITY},
blocking=True, blocking=True,
) )
is_on_updated_callback(False) mock_api._on_is_on_updated(False)
mock_api.send_key_command.assert_called_with("POWER", "SHORT") mock_api.send_key_command.assert_called_with("POWER", "SHORT")
@ -97,7 +64,7 @@ async def test_remote_toggles(
{"entity_id": REMOTE_ENTITY}, {"entity_id": REMOTE_ENTITY},
blocking=True, blocking=True,
) )
is_on_updated_callback(True) mock_api._on_is_on_updated(True)
mock_api.send_key_command.assert_called_with("POWER", "SHORT") mock_api.send_key_command.assert_called_with("POWER", "SHORT")
assert mock_api.send_key_command.call_count == 2 assert mock_api.send_key_command.call_count == 2