From 9ba0475644a54e822d02eff503154180b44d9ab9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 23 Mar 2022 22:12:12 +0100 Subject: [PATCH] Use callback to get app_list in SamsungTV (#68506) Co-authored-by: epenet --- homeassistant/components/samsungtv/bridge.py | 69 +++++++++++-------- .../components/samsungtv/media_player.py | 39 ++++++++--- tests/components/samsungtv/conftest.py | 29 +++++++- tests/components/samsungtv/const.py | 56 ++++++++------- .../components/samsungtv/test_config_flow.py | 3 - .../components/samsungtv/test_media_player.py | 6 +- 6 files changed, 133 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index a135ca27db5..73764d51dc0 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -14,7 +14,11 @@ from samsungtvws.async_remote import SamsungTVWSAsyncRemote from samsungtvws.async_rest import SamsungTVAsyncRest from samsungtvws.command import SamsungTVCommand from samsungtvws.encrypted.remote import SamsungTVEncryptedWSAsyncRemote -from samsungtvws.event import MS_ERROR_EVENT +from samsungtvws.event import ( + ED_INSTALLED_APP_EVENT, + MS_ERROR_EVENT, + parse_installed_app, +) from samsungtvws.exceptions import ConnectionFailure, HttpApiError from samsungtvws.remote import ChannelEmitCommand, SendRemoteKey from websockets.exceptions import ConnectionClosedError, WebSocketException @@ -128,6 +132,7 @@ class SamsungTVBridge(ABC): self._reauth_callback: CALLBACK_TYPE | None = None self._reload_callback: CALLBACK_TYPE | None = None self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None + self._app_list_callback: Callable[[dict[str, str]], None] | None = None def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" @@ -143,6 +148,12 @@ class SamsungTVBridge(ABC): """Register a callback function.""" self._update_config_entry = func + def register_app_list_callback( + self, func: Callable[[dict[str, str]], None] + ) -> None: + """Register app_list callback function.""" + self._app_list_callback = func + @abstractmethod async def async_try_connect(self) -> str: """Try to connect to the TV.""" @@ -151,9 +162,15 @@ class SamsungTVBridge(ABC): async def async_device_info(self) -> dict[str, Any] | None: """Try to gather infos of this TV.""" - @abstractmethod - async def async_get_app_list(self) -> dict[str, str] | None: - """Get installed app list.""" + async def async_request_app_list(self) -> None: + """Request app list.""" + # Overridden in SamsungTVWSBridge + LOGGER.debug( + "App list request is not supported on %s TV: %s", + self.method, + self.host, + ) + self._notify_app_list_callback({}) @abstractmethod async def async_is_on(self) -> bool: @@ -186,6 +203,11 @@ class SamsungTVBridge(ABC): if self._update_config_entry is not None: self._update_config_entry(updates) + def _notify_app_list_callback(self, app_list: dict[str, str]) -> None: + """Notify update config callback.""" + if self._app_list_callback is not None: + self._app_list_callback(app_list) + class SamsungTVLegacyBridge(SamsungTVBridge): """The Bridge for Legacy TVs.""" @@ -206,10 +228,6 @@ class SamsungTVLegacyBridge(SamsungTVBridge): } self._remote: Remote | None = None - async def async_get_app_list(self) -> dict[str, str]: - """Get installed app list.""" - return {} - async def async_is_on(self) -> bool: """Tells if the TV is on.""" return await self.hass.async_add_executor_job(self._is_on) @@ -345,26 +363,10 @@ class SamsungTVWSBridge(SamsungTVBridge): if entry_data: self.token = entry_data.get(CONF_TOKEN) self._rest_api: SamsungTVAsyncRest | None = None - self._app_list: dict[str, str] | None = None self._device_info: dict[str, Any] | None = None self._remote: SamsungTVWSAsyncRemote | None = None self._remote_lock = asyncio.Lock() - async def async_get_app_list(self) -> dict[str, str] | None: - """Get installed app list.""" - if self._app_list is None: - if remote := await self._async_get_remote(): - raw_app_list = await remote.app_list() - self._app_list = { - app["name"]: app["appId"] - for app in sorted( - raw_app_list or [], - key=lambda app: cast(str, app["name"]), - ) - } - LOGGER.debug("Generated app list: %s", self._app_list) - return self._app_list - def _get_device_spec(self, key: str) -> Any | None: """Check if a flag exists in latest device info.""" if not ((info := self._device_info) and (device := info.get("device"))): @@ -456,6 +458,10 @@ class SamsungTVWSBridge(SamsungTVBridge): """Send the launch_app command using websocket protocol.""" await self._async_send_commands([ChannelEmitCommand.launch_app(app_id)]) + async def async_request_app_list(self) -> None: + """Get installed app list.""" + await self._async_send_commands([ChannelEmitCommand.get_installed_app()]) + async def async_send_keys(self, keys: list[str]) -> None: """Send a list of keys using websocket protocol.""" await self._async_send_commands([SendRemoteKey.click(key) for key in keys]) @@ -546,6 +552,17 @@ class SamsungTVWSBridge(SamsungTVBridge): def _remote_event(self, event: str, response: Any) -> None: """Received event from remote websocket.""" + if event == ED_INSTALLED_APP_EVENT: + self._notify_app_list_callback( + { + app["name"]: app["appId"] + for app in sorted( + parse_installed_app(response), + key=lambda app: cast(str, app["name"]), + ) + } + ) + return if event == MS_ERROR_EVENT: # { 'event': 'ms.error', # 'data': {'message': 'unrecognized method value : ms.remote.control'}} @@ -608,10 +625,6 @@ class SamsungTVEncryptedBridge(SamsungTVBridge): self._remote: SamsungTVEncryptedWSAsyncRemote | None = None self._remote_lock = asyncio.Lock() - async def async_get_app_list(self) -> dict[str, str]: - """Get installed app list.""" - return {} - async def async_is_on(self) -> bool: """Tells if the TV is on.""" LOGGER.debug("Checking if TV %s is on using websocket", self.host) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index a293321d311..677dbfab66a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -1,6 +1,7 @@ """Support for interface with an Samsung TV.""" from __future__ import annotations +import asyncio from datetime import datetime, timedelta from typing import Any @@ -75,6 +76,9 @@ SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta seconds=5 ) +# Max delay waiting for app_list to return, as some TVs simply ignore the request +APP_LIST_DELAY = 3 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -119,6 +123,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) self._app_list: dict[str, str] | None = None + self._app_list_event: asyncio.Event = asyncio.Event() if config_entry.data.get(CONF_METHOD) != METHOD_ENCRYPTED_WEBSOCKET: # Encrypted websockets currently only support ON/OFF status @@ -146,6 +151,18 @@ class SamsungTVDevice(MediaPlayerEntity): self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) + self._bridge.register_app_list_callback(self._app_list_callback) + + def _update_sources(self) -> None: + self._attr_source_list = list(SOURCES) + if app_list := self._app_list: + self._attr_source_list.extend(app_list) + + def _app_list_callback(self, app_list: dict[str, str]) -> None: + """App list callback.""" + self._app_list = app_list + self._update_sources() + self._app_list_event.set() def access_denied(self) -> None: """Access denied callback.""" @@ -173,14 +190,20 @@ class SamsungTVDevice(MediaPlayerEntity): STATE_ON if await self._bridge.async_is_on() else STATE_OFF ) - if self._attr_state == STATE_ON and self._app_list is None: - self._app_list = {} # Ensure that we don't update it twice in parallel - await self._async_update_app_list() - - async def _async_update_app_list(self) -> None: - self._app_list = await self._bridge.async_get_app_list() - if self._app_list is not None: - self._attr_source_list.extend(self._app_list) + if self._attr_state == STATE_ON and not self._app_list_event.is_set(): + await self._bridge.async_request_app_list() + if self._app_list_event.is_set(): + # The try+wait_for is a bit expensive so we should try not to + # enter it unless we have to (Python 3.11 will have zero cost try) + return + try: + await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY) + except asyncio.TimeoutError as err: + # No need to try again + self._app_list_event.set() + LOGGER.debug( + "Failed to load app list from %s: %s", self._host, err.__repr__() + ) async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 3d9ea74e346..3fc11d7c07a 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -9,11 +9,14 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from samsungctl import Remote from samsungtvws.async_remote import SamsungTVWSAsyncRemote +from samsungtvws.command import SamsungTVCommand from samsungtvws.encrypted.remote import SamsungTVEncryptedWSAsyncRemote +from samsungtvws.event import ED_INSTALLED_APP_EVENT +from samsungtvws.remote import ChannelEmitCommand import homeassistant.util.dt as dt_util -from .const import SAMPLE_APP_LIST, SAMPLE_DEVICE_INFO_WIFI +from .const import SAMPLE_DEVICE_INFO_WIFI @pytest.fixture(autouse=True) @@ -26,6 +29,13 @@ def fake_host_fixture() -> None: yield +@pytest.fixture(autouse=True) +def app_list_delay_fixture() -> None: + """Patch APP_LIST_DELAY.""" + with patch("homeassistant.components.samsungtv.media_player.APP_LIST_DELAY", 0): + yield + + @pytest.fixture(name="remote") def remote_fixture() -> Mock: """Patch the samsungctl Remote.""" @@ -56,19 +66,32 @@ def remotews_fixture() -> Mock: remotews = Mock(SamsungTVWSAsyncRemote) remotews.__aenter__ = AsyncMock(return_value=remotews) remotews.__aexit__ = AsyncMock() - remotews.app_list.return_value = SAMPLE_APP_LIST remotews.token = "FAKE_TOKEN" + remotews.app_list_data = None - def _start_listening( + async def _start_listening( ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None ): remotews.ws_event_callback = ws_event_callback + async def _send_commands(commands: list[SamsungTVCommand]): + if ( + len(commands) == 1 + and isinstance(commands[0], ChannelEmitCommand) + and commands[0].params["event"] == "ed.installedApp.get" + and remotews.app_list_data is not None + ): + remotews.raise_mock_ws_event_callback( + ED_INSTALLED_APP_EVENT, + remotews.app_list_data, + ) + def _mock_ws_event_callback(event: str, response: Any): if remotews.ws_event_callback: remotews.ws_event_callback(event, response) remotews.start_listening.side_effect = _start_listening + remotews.send_commands.side_effect = _send_commands remotews.raise_mock_ws_event_callback = Mock(side_effect=_mock_ws_event_callback) with patch( diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 11e92481f21..64c3c6add8e 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -1,4 +1,6 @@ """Constants for the samsungtv tests.""" +from samsungtvws.event import ED_INSTALLED_APP_EVENT + from homeassistant.components.samsungtv.const import CONF_SESSION_ID from homeassistant.const import ( CONF_HOST, @@ -24,30 +26,6 @@ MOCK_ENTRYDATA_ENCRYPTED_WS = { CONF_SESSION_ID: "2", } -SAMPLE_APP_LIST = [ - { - "appId": "111299001912", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", - "is_lock": 0, - "name": "YouTube", - }, - { - "appId": "3201608010191", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", - "is_lock": 0, - "name": "Deezer", - }, - { - "appId": "3201606009684", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", - "is_lock": 0, - "name": "Spotify - Music and Podcasts", - }, -] - SAMPLE_DEVICE_INFO_WIFI = { "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", "device": { @@ -127,3 +105,33 @@ SAMPLE_DEVICE_INFO_UE48JU6400 = { "type": "Samsung SmartTV", "uri": "https://1.2.3.4:8002/api/v2/", } + +SAMPLE_EVENT_ED_INSTALLED_APP = { + "event": ED_INSTALLED_APP_EVENT, + "from": "host", + "data": { + "data": [ + { + "appId": "111299001912", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", + "is_lock": 0, + "name": "YouTube", + }, + { + "appId": "3201608010191", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", + "is_lock": 0, + "name": "Deezer", + }, + { + "appId": "3201606009684", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", + "is_lock": 0, + "name": "Spotify - Music and Podcasts", + }, + ] + }, +} diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 72f23b01350..567b53646d7 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -55,7 +55,6 @@ from . import setup_samsungtv_entry from .const import ( MOCK_CONFIG_ENCRYPTED_WS, MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_APP_LIST, SAMPLE_DEVICE_INFO_FRAME, ) @@ -910,7 +909,6 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: remote = Mock(SamsungTVWSAsyncRemote) remote.__aenter__ = AsyncMock(return_value=remote) remote.__aexit__ = AsyncMock(return_value=False) - remote.app_list.return_value = SAMPLE_APP_LIST rest_api_class.return_value.rest_device_info = AsyncMock( return_value={ "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", @@ -957,7 +955,6 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: remote = Mock(SamsungTVWSAsyncRemote) remote.__aenter__ = AsyncMock(return_value=remote) remote.__aexit__ = AsyncMock(return_value=False) - remote.app_list.return_value = SAMPLE_APP_LIST rest_api_class.return_value.rest_device_info = AsyncMock( return_value={ "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index c62c20f2f36..96b939d08b9 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -72,8 +72,8 @@ import homeassistant.util.dt as dt_util from . import setup_samsungtv_entry from .const import ( MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_APP_LIST, SAMPLE_DEVICE_INFO_FRAME, + SAMPLE_EVENT_ED_INSTALLED_APP, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -175,7 +175,6 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: remote = Mock(SamsungTVWSAsyncRemote) remote.__aenter__ = AsyncMock(return_value=remote) remote.__aexit__ = AsyncMock() - remote.app_list.return_value = SAMPLE_APP_LIST remote.token = "123456789" remote_class.return_value = remote @@ -213,7 +212,6 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non remote = Mock(SamsungTVWSAsyncRemote) remote.__aenter__ = AsyncMock(return_value=remote) remote.__aexit__ = AsyncMock() - remote.app_list.return_value = SAMPLE_APP_LIST remote.token = "987654321" remote_class.return_value = remote assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) @@ -738,6 +736,7 @@ async def test_turn_off_websocket( hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off.""" + remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], @@ -1178,6 +1177,7 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for select_source.""" + remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP await setup_samsungtv(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock()