From ba7388546efb380dccf3a7bc07841633e9002e43 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 21 Jun 2024 11:17:04 -0700 Subject: [PATCH] Implement Android TV Remote browse media with apps and activity list (#117126) --- .../androidtv_remote/config_flow.py | 101 +++++++++++++++- .../components/androidtv_remote/const.py | 3 + .../components/androidtv_remote/entity.py | 5 +- .../androidtv_remote/media_player.py | 41 ++++++- .../components/androidtv_remote/remote.py | 24 +++- .../components/androidtv_remote/strings.json | 11 ++ .../androidtv_remote/test_config_flow.py | 108 ++++++++++++++++-- .../androidtv_remote/test_media_player.py | 88 ++++++++++++++ .../androidtv_remote/test_remote.py | 22 ++++ 9 files changed, 388 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index a9b32c22700..813c0eda14b 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -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 "", + }, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py index 9d2a7fcb240..540c8186e20 100644 --- a/homeassistant/components/androidtv_remote/const.py +++ b/homeassistant/components/androidtv_remote/const.py @@ -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" diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index fa070e1ec18..44b2d2a5f20 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -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 diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 571eab4a15b..554aa2f2946 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -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: diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 72387a54bf0..c9a261c8735 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -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: diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index da9bdd8bd3b..33970171d40 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -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" + } } } } diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 062b9a4a55c..93c9067d1c8 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -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} diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index c7937e9e02d..ad7c049e32f 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -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: diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index eba955a6aba..7ca63685747 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -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