Implement Android TV Remote browse media with apps and activity list (#117126)

This commit is contained in:
tronikos 2024-06-21 11:17:04 -07:00 committed by GitHub
parent c13efa3664
commit ba7388546e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 388 additions and 15 deletions

View File

@ -24,12 +24,22 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_ENABLE_IME, DOMAIN
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
from .helpers import create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__)
APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
@ -213,17 +223,46 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Android TV Remote options flow."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
super().__init__(config_entry)
self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {})
self._conf_app_id: str | None = None
@callback
def _save_config(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Save the updated options."""
new_data = {k: v for k, v in data.items() if k not in [CONF_APPS]}
if self._apps:
new_data[CONF_APPS] = self._apps
return self.async_create_entry(title="", data=new_data)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
if sel_app := user_input.get(CONF_APPS):
return await self.async_step_apps(None, sel_app)
return self._save_config(user_input)
apps_list = {
k: f"{v[CONF_APP_NAME]} ({k})" if CONF_APP_NAME in v else k
for k, v in self._apps.items()
}
apps = [SelectOptionDict(value=APPS_NEW_ID, label="Add new")] + [
SelectOptionDict(value=k, label=v) for k, v in apps_list.items()
]
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(CONF_APPS): SelectSelector(
SelectSelectorConfig(
options=apps, mode=SelectSelectorMode.DROPDOWN
)
),
vol.Required(
CONF_ENABLE_IME,
default=get_enable_ime(self.config_entry),
@ -231,3 +270,61 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry):
}
),
)
async def async_step_apps(
self, user_input: dict[str, Any] | None = None, app_id: str | None = None
) -> ConfigFlowResult:
"""Handle options flow for apps list."""
if app_id is not None:
self._conf_app_id = app_id if app_id != APPS_NEW_ID else None
return self._async_apps_form(app_id)
if user_input is not None:
app_id = user_input.get(CONF_APP_ID, self._conf_app_id)
if app_id:
if user_input.get(CONF_APP_DELETE, False):
self._apps.pop(app_id)
else:
self._apps[app_id] = {
CONF_APP_NAME: user_input.get(CONF_APP_NAME, ""),
CONF_APP_ICON: user_input.get(CONF_APP_ICON, ""),
}
return await self.async_step_init()
@callback
def _async_apps_form(self, app_id: str) -> ConfigFlowResult:
"""Return configuration form for apps."""
app_schema = {
vol.Optional(
CONF_APP_NAME,
description={
"suggested_value": self._apps[app_id].get(CONF_APP_NAME, "")
if app_id in self._apps
else ""
},
): str,
vol.Optional(
CONF_APP_ICON,
description={
"suggested_value": self._apps[app_id].get(CONF_APP_ICON, "")
if app_id in self._apps
else ""
},
): str,
}
if app_id == APPS_NEW_ID:
data_schema = vol.Schema({**app_schema, vol.Optional(CONF_APP_ID): str})
else:
data_schema = vol.Schema(
{**app_schema, vol.Optional(CONF_APP_DELETE, default=False): bool}
)
return self.async_show_form(
step_id="apps",
data_schema=data_schema,
description_placeholders={
"app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "",
},
)

View File

@ -6,5 +6,8 @@ from typing import Final
DOMAIN: Final = "androidtv_remote"
CONF_APPS = "apps"
CONF_ENABLE_IME: Final = "enable_ime"
CONF_ENABLE_IME_DEFAULT_VALUE: Final = True
CONF_APP_NAME = "app_name"
CONF_APP_ICON = "app_icon"

View File

@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.config_entries import ConfigEntry
@ -11,7 +13,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .const import CONF_APPS, DOMAIN
class AndroidTVRemoteBaseEntity(Entity):
@ -26,6 +28,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api = api
self._host = config_entry.data[CONF_HOST]
self._name = config_entry.data[CONF_NAME]
self._apps: dict[str, Any] = config_entry.options.get(CONF_APPS, {})
self._attr_unique_id = config_entry.unique_id
self._attr_is_on = api.is_on
device_info = api.device_info

View File

@ -8,17 +8,20 @@ from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.components.media_player import (
MediaClass,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.components.media_player.browse_media import BrowseMedia
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
@ -50,6 +53,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
def __init__(
@ -65,7 +69,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
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
self._attr_app_name = (
self._apps[current_app].get(CONF_APP_NAME, current_app)
if current_app in self._apps
else current_app
)
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
"""Update volume info."""
@ -176,12 +184,41 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
await self._channel_set_task
return
if media_type == MediaType.URL:
if media_type in [MediaType.URL, MediaType.APP]:
self._send_launch_app_command(media_id)
return
raise ValueError(f"Invalid media type: {media_type}")
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Browse apps."""
children = [
BrowseMedia(
media_class=MediaClass.APP,
media_content_type=MediaType.APP,
media_content_id=app_id,
title=app.get(CONF_APP_NAME, ""),
thumbnail=app.get(CONF_APP_ICON, ""),
can_play=False,
can_expand=False,
)
for app_id, app in self._apps.items()
]
return BrowseMedia(
title="Applications",
media_class=MediaClass.DIRECTORY,
media_content_id="apps",
media_content_type=MediaType.APPS,
children_media_class=MediaClass.APP,
can_play=False,
can_expand=True,
children=children,
)
async def _send_key_commands(
self, key_codes: list[str], delay_secs: float = 0.1
) -> None:

View File

@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_NAME
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
@ -41,17 +42,28 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
_attr_supported_features = RemoteEntityFeature.ACTIVITY
def _update_current_app(self, current_app: str) -> None:
"""Update current app info."""
self._attr_current_activity = (
self._apps[current_app].get(CONF_APP_NAME, current_app)
if current_app in self._apps
else current_app
)
@callback
def _current_app_updated(self, current_app: str) -> None:
"""Update the state when the current app changes."""
self._attr_current_activity = current_app
self._update_current_app(current_app)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self._attr_current_activity = self._api.current_app
self._attr_activity_list = [
app.get(CONF_APP_NAME, "") for app in self._apps.values()
]
self._update_current_app(self._api.current_app)
self._api.add_current_app_updated_callback(self._current_app_updated)
async def async_will_remove_from_hass(self) -> None:
@ -66,6 +78,14 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
self._send_key_command("POWER")
activity = kwargs.get(ATTR_ACTIVITY, "")
if activity:
activity = next(
(
app_id
for app_id, app in self._apps.items()
if app.get(CONF_APP_NAME, "") == activity
),
activity,
)
self._send_launch_app_command(activity)
async def async_turn_off(self, **kwargs: Any) -> None:

View File

@ -39,8 +39,19 @@
"step": {
"init": {
"data": {
"apps": "Configure applications list",
"enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard."
}
},
"apps": {
"title": "Configure Android Apps",
"description": "Configure application id {app_id}",
"data": {
"app_name": "Application Name",
"app_id": "Application ID",
"app_icon": "Application Icon",
"app_delete": "Check to delete this application"
}
}
}
}

View File

@ -7,7 +7,18 @@ from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.androidtv_remote.const import DOMAIN
from homeassistant.components.androidtv_remote.config_flow import (
APPS_NEW_ID,
CONF_APP_DELETE,
CONF_APP_ID,
)
from homeassistant.components.androidtv_remote.const import (
CONF_APP_ICON,
CONF_APP_NAME,
CONF_APPS,
CONF_ENABLE_IME,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -886,14 +897,14 @@ async def test_options_flow(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
data_schema = result["data_schema"].schema
assert set(data_schema) == {"enable_ime"}
assert set(data_schema) == {CONF_APPS, CONF_ENABLE_IME}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"enable_ime": False},
user_input={CONF_ENABLE_IME: False},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {"enable_ime": False}
assert mock_config_entry.options == {CONF_ENABLE_IME: False}
await hass.async_block_till_done()
assert mock_api.disconnect.call_count == 1
@ -903,10 +914,10 @@ async def test_options_flow(
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"enable_ime": False},
user_input={CONF_ENABLE_IME: False},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {"enable_ime": False}
assert mock_config_entry.options == {CONF_ENABLE_IME: False}
await hass.async_block_till_done()
assert mock_api.disconnect.call_count == 1
@ -916,11 +927,92 @@ async def test_options_flow(
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"enable_ime": True},
user_input={CONF_ENABLE_IME: True},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {"enable_ime": True}
assert mock_config_entry.options == {CONF_ENABLE_IME: True}
await hass.async_block_till_done()
assert mock_api.disconnect.call_count == 2
assert mock_api.async_connect.call_count == 3
# test app form with new app
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: APPS_NEW_ID,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "apps"
# test save value for new app
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_ID: "app1",
CONF_APP_NAME: "App1",
CONF_APP_ICON: "Icon1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
# test app form with existing app
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: "app1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "apps"
# test change value in apps form
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_NAME: "Application1",
CONF_APP_ICON: "Icon1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {
CONF_APPS: {"app1": {CONF_APP_NAME: "Application1", CONF_APP_ICON: "Icon1"}},
CONF_ENABLE_IME: True,
}
await hass.async_block_till_done()
# test app form for delete
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APPS: "app1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "apps"
# test delete app1
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_APP_DELETE: True,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {CONF_ENABLE_IME: True}

View File

@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
MEDIA_PLAYER_ENTITY = "media_player.my_android_tv"
@ -19,6 +20,9 @@ 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.options = {
"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}
}
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
@ -39,6 +43,13 @@ async def test_media_player_receives_push_updates(
== "com.google.android.tvlauncher"
)
mock_api._on_current_app_updated("com.google.android.youtube.tv")
assert (
hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_id")
== "com.google.android.youtube.tv"
)
assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_name") == "YouTube"
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
@ -267,6 +278,18 @@ async def test_media_player_play_media(
)
mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com")
await hass.services.async_call(
"media_player",
"play_media",
{
"entity_id": MEDIA_PLAYER_ENTITY,
"media_content_type": "app",
"media_content_id": "tv.twitch.android.app",
},
blocking=True,
)
mock_api.send_launch_app_command.assert_called_with("tv.twitch.android.app")
with pytest.raises(ValueError):
await hass.services.async_call(
"media_player",
@ -292,6 +315,71 @@ async def test_media_player_play_media(
)
async def test_browse_media(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
mock_config_entry: MockConfigEntry,
mock_api: MagicMock,
) -> None:
"""Test the Android TV Remote media player browse media."""
mock_config_entry.options = {
"apps": {
"com.google.android.youtube.tv": {
"app_name": "YouTube",
"app_icon": "https://www.youtube.com/icon.png",
},
"org.xbmc.kodi": {"app_name": "Kodi"},
}
}
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
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": MEDIA_PLAYER_ENTITY,
}
)
response = await client.receive_json()
assert response["success"]
assert {
"title": "Applications",
"media_class": "directory",
"media_content_type": "apps",
"media_content_id": "apps",
"children_media_class": "app",
"can_play": False,
"can_expand": True,
"thumbnail": None,
"not_shown": 0,
"children": [
{
"title": "YouTube",
"media_class": "app",
"media_content_type": "app",
"media_content_id": "com.google.android.youtube.tv",
"children_media_class": None,
"can_play": False,
"can_expand": False,
"thumbnail": "https://www.youtube.com/icon.png",
},
{
"title": "Kodi",
"media_class": "app",
"media_content_type": "app",
"media_content_id": "org.xbmc.kodi",
"children_media_class": None,
"can_play": False,
"can_expand": False,
"thumbnail": "",
},
],
} == response["result"]
async def test_media_player_connection_closed(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:

View File

@ -19,6 +19,9 @@ async def test_remote_receives_push_updates(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote receives push updates and state is updated."""
mock_config_entry.options = {
"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}
}
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
@ -34,6 +37,11 @@ async def test_remote_receives_push_updates(
hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1"
)
mock_api._on_current_app_updated("com.google.android.youtube.tv")
assert (
hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "YouTube"
)
mock_api._on_is_available_updated(False)
assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE)
@ -45,6 +53,9 @@ async def test_remote_toggles(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock
) -> None:
"""Test the Android TV Remote toggles."""
mock_config_entry.options = {
"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}
}
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
@ -81,6 +92,17 @@ async def test_remote_toggles(
assert mock_api.send_key_command.call_count == 2
assert mock_api.send_launch_app_command.call_count == 1
await hass.services.async_call(
"remote",
"turn_on",
{"entity_id": REMOTE_ENTITY, "activity": "YouTube"},
blocking=True,
)
mock_api.send_key_command.send_launch_app_command("com.google.android.youtube.tv")
assert mock_api.send_key_command.call_count == 2
assert mock_api.send_launch_app_command.call_count == 2
async def test_remote_send_command(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock