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

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

Merged
merged 6 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
101 changes: 99 additions & 2 deletions homeassistant/components/androidtv_remote/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@
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,
Expand Down Expand Up @@ -213,21 +223,108 @@ def async_get_options_flow(
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),
): bool,
}
),
)

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 "",
},
)
3 changes: 3 additions & 0 deletions homeassistant/components/androidtv_remote/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@

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"
5 changes: 4 additions & 1 deletion homeassistant/components/androidtv_remote/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

from androidtvremote2 import AndroidTVRemote, ConnectionClosed

from homeassistant.config_entries import ConfigEntry
Expand All @@ -11,7 +13,7 @@
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):
Expand All @@ -26,6 +28,7 @@ def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
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
Expand Down
41 changes: 39 additions & 2 deletions homeassistant/components/androidtv_remote/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@
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
Expand Down Expand Up @@ -50,6 +53,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
)

def __init__(
Expand All @@ -65,7 +69,11 @@ def __init__(
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."""
Expand Down Expand Up @@ -176,12 +184,41 @@ async def async_play_media(
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:
Expand Down
24 changes: 22 additions & 2 deletions homeassistant/components/androidtv_remote/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_NAME
from .entity import AndroidTVRemoteBaseEntity

PARALLEL_UPDATES = 0
Expand All @@ -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:
Expand All @@ -66,6 +78,14 @@ async def async_turn_on(self, **kwargs: Any) -> None:
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:
Expand Down
11 changes: 11 additions & 0 deletions homeassistant/components/androidtv_remote/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Expand Down
Loading