diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 69289dca274..664ce1fa439 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -125,11 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, CONF_TOKEN: bridge.token} ) - def new_token_callback() -> None: - """Update config entry with the new token.""" - hass.add_job(_update_token) - - bridge.register_new_token_callback(new_token_callback) + bridge.register_new_token_callback(_update_token) async def stop_bridge(event: Event) -> None: """Stop SamsungTV bridge connection.""" diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 39f6a5e4f36..3a06a9ff906 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -2,16 +2,18 @@ from __future__ import annotations from abc import ABC, abstractmethod +import asyncio from asyncio.exceptions import TimeoutError as AsyncioTimeoutError import contextlib from typing import Any, cast from samsungctl import Remote from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse -from samsungtvws import SamsungTVWS +from samsungtvws.async_remote import SamsungTVWSAsyncRemote from samsungtvws.async_rest import SamsungTVAsyncRest from samsungtvws.exceptions import ConnectionFailure, HttpApiError -from websocket import WebSocketException +from samsungtvws.remote import ChannelEmitCommand, SendRemoteKey +from websockets.exceptions import WebSocketException from homeassistant.const import ( CONF_HOST, @@ -298,7 +300,8 @@ class SamsungTVWSBridge(SamsungTVBridge): self.token = token self._rest_api: SamsungTVAsyncRest | None = None self._app_list: dict[str, str] | None = None - self._remote: SamsungTVWS | None = None + self._remote: SamsungTVWSAsyncRemote | None = None + self._remote_lock = asyncio.Lock() async def async_mac_from_device(self) -> str | None: """Try to fetch the mac address of the TV.""" @@ -306,39 +309,27 @@ class SamsungTVWSBridge(SamsungTVBridge): return mac_from_device_info(info) if info else None async def async_get_app_list(self) -> dict[str, str] | None: - """Get installed app list.""" - return await self.hass.async_add_executor_job(self._get_app_list) - - def _get_app_list(self) -> dict[str, str] | None: """Get installed app list.""" if self._app_list is None: - if remote := self._get_remote(): - raw_app_list = remote.app_list() + 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"]) + raw_app_list or [], + key=lambda app: cast(str, app["name"]), ) } - + LOGGER.debug("Generated app list: %s", self._app_list) return self._app_list async def async_is_on(self) -> bool: """Tells if the TV is on.""" - return await self.hass.async_add_executor_job(self._is_on) - - def _is_on(self) -> bool: - """Tells if the TV is on.""" - if self._remote is not None: - self._close_remote() - - return self._get_remote() is not None + if remote := await self._async_get_remote(): + return remote.is_alive() + return False async def async_try_connect(self) -> str: - """Try to connect to the Websocket TV.""" - return await self.hass.async_add_executor_job(self._try_connect) - - def _try_connect(self) -> str: """Try to connect to the Websocket TV.""" for self.port in WEBSOCKET_PORTS: config = { @@ -353,14 +344,14 @@ class SamsungTVWSBridge(SamsungTVBridge): result = None try: LOGGER.debug("Try config: %s", config) - with SamsungTVWS( + async with SamsungTVWSAsyncRemote( host=self.host, port=self.port, token=self.token, timeout=TIMEOUT_REQUEST, name=VALUE_CONF_NAME, ) as remote: - remote.open() + await remote.open() self.token = remote.token LOGGER.debug("Working config: %s", config) return RESULT_SUCCESS @@ -369,7 +360,7 @@ class SamsungTVWSBridge(SamsungTVBridge): "Working but unsupported config: %s, error: %s", config, err ) result = RESULT_NOT_SUPPORTED - except (OSError, ConnectionFailure) as err: + except (OSError, AsyncioTimeoutError, ConnectionFailure) as err: LOGGER.debug("Failing config: %s, error: %s", config, err) # pylint: disable=useless-else-on-loop else: @@ -397,10 +388,6 @@ class SamsungTVWSBridge(SamsungTVBridge): return None async def async_send_key(self, key: str, key_type: str | None = None) -> None: - """Send the key using websocket protocol.""" - await self.hass.async_add_executor_job(self._send_key, key, key_type) - - def _send_key(self, key: str, key_type: str | None = None) -> None: """Send the key using websocket protocol.""" if key == "KEY_POWEROFF": key = "KEY_POWER" @@ -409,11 +396,13 @@ class SamsungTVWSBridge(SamsungTVBridge): retry_count = 1 for _ in range(retry_count + 1): try: - if remote := self._get_remote(): + if remote := await self._async_get_remote(): if key_type == "run_app": - remote.run_app(key) + await remote.send_command( + ChannelEmitCommand.launch_app(key) + ) else: - remote.send_key(key) + await remote.send_command(SendRemoteKey.click(key)) break except ( BrokenPipeError, @@ -426,29 +415,40 @@ class SamsungTVWSBridge(SamsungTVBridge): # Different reasons, e.g. hostname not resolveable pass - def _get_remote(self) -> SamsungTVWS | None: + async def _async_get_remote(self) -> SamsungTVWSAsyncRemote | None: """Create or return a remote control instance.""" - if self._remote is None: + if (remote := self._remote) and remote.is_alive(): + # If we have one then try to use it + return remote + + async with self._remote_lock: + # If we don't have one make sure we do it under the lock + # so we don't make two do due a race to get the remote + return await self._async_get_remote_under_lock() + + async def _async_get_remote_under_lock(self) -> SamsungTVWSAsyncRemote | None: + """Create or return a remote control instance.""" + if self._remote is None or not self._remote.is_alive(): # We need to create a new instance to reconnect. try: LOGGER.debug( "Create SamsungTVWSBridge for %s (%s)", CONF_NAME, self.host ) assert self.port - self._remote = SamsungTVWS( + self._remote = SamsungTVWSAsyncRemote( host=self.host, port=self.port, token=self.token, timeout=TIMEOUT_WEBSOCKET, name=VALUE_CONF_NAME, ) - self._remote.open() + await self._remote.start_listening() # This is only happening when the auth was switched to DENY # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket except ConnectionFailure as err: LOGGER.debug("ConnectionFailure %s", err.__repr__()) self._notify_reauth_callback() - except (WebSocketException, OSError) as err: + except (WebSocketException, AsyncioTimeoutError, OSError) as err: LOGGER.debug("WebSocketException, OSError %s", err.__repr__()) self._remote = None else: @@ -465,15 +465,11 @@ class SamsungTVWSBridge(SamsungTVBridge): return self._remote async def async_close_remote(self) -> None: - """Close remote object.""" - await self.hass.async_add_executor_job(self._close_remote) - - def _close_remote(self) -> None: """Close remote object.""" try: if self._remote is not None: # Close the current remote connection - self._remote.close() + await self._remote.close() self._remote = None except OSError: LOGGER.debug("Could not establish connection") diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index e92caac9fc4..7975c291fe3 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -1,10 +1,10 @@ """Fixtures for Samsung TV.""" from datetime import datetime -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from samsungctl import Remote -from samsungtvws import SamsungTVWS +from samsungtvws.async_remote import SamsungTVWSAsyncRemote import homeassistant.util.dt as dt_util @@ -56,11 +56,11 @@ def rest_api_fixture() -> Mock: def remotews_fixture() -> Mock: """Patch the samsungtvws SamsungTVWS.""" with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS" + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", ) as remotews_class: - remotews = Mock(SamsungTVWS) - remotews.__enter__ = Mock(return_value=remotews) - remotews.__exit__ = 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_class.return_value = remotews diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 11ca69b91ef..94056a71a50 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -4,9 +4,9 @@ from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest from samsungctl.exceptions import AccessDenied, UnhandledResponse -from samsungtvws import SamsungTVWS +from samsungtvws.async_remote import SamsungTVWSAsyncRemote from samsungtvws.exceptions import ConnectionFailure, HttpApiError -from websocket import WebSocketException, WebSocketProtocolException +from websockets.exceptions import WebSocketException, WebSocketProtocolError from homeassistant import config_entries from homeassistant.components import dhcp, ssdp, zeroconf @@ -272,8 +272,8 @@ async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", - side_effect=WebSocketProtocolException("Boom"), + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", + side_effect=WebSocketProtocolError("Boom"), ): # websocket device not supported result = await hass.config_entries.flow.async_init( @@ -289,7 +289,7 @@ async def test_user_not_successful(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=OSError("Boom"), ): result = await hass.config_entries.flow.async_init( @@ -305,7 +305,7 @@ async def test_user_not_successful_2(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=ConnectionFailure("Boom"), ): result = await hass.config_entries.flow.async_init( @@ -464,9 +464,9 @@ async def test_ssdp_websocket_not_supported( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", ) as remotews, patch.object( - remotews, "open", side_effect=WebSocketProtocolException("Boom") + remotews, "open", side_effect=WebSocketProtocolError("Boom") ): # device not supported result = await hass.config_entries.flow.async_init( @@ -497,7 +497,7 @@ async def test_ssdp_not_successful(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=OSError("Boom"), ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", @@ -526,7 +526,7 @@ async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=ConnectionFailure("Boom"), ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", @@ -830,13 +830,13 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS" + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" ) as remotews, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class: - remote = Mock(SamsungTVWS) - remote.__enter__ = Mock(return_value=remote) - remote.__exit__ = Mock(return_value=False) + 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={ @@ -876,15 +876,15 @@ async def test_websocket_no_mac(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS" + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" ) as remotews, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class, patch( "getmac.get_mac_address", return_value="gg:hh:ii:ll:mm:nn" ): - remote = Mock(SamsungTVWS) - remote.__enter__ = Mock(return_value=remote) - remote.__exit__ = Mock(return_value=False) + 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={ @@ -964,15 +964,15 @@ async def test_autodetect_legacy(hass: HomeAssistant) -> None: async def test_autodetect_none(hass: HomeAssistant) -> None: """Test for send key with autodetection of protocol.""" mock_remotews = Mock() - mock_remotews.__enter__ = Mock(return_value=mock_remotews) - mock_remotews.__exit__ = Mock() + mock_remotews.__aenter__ = AsyncMock(return_value=mock_remotews) + mock_remotews.__aexit__ = AsyncMock() mock_remotews.open = Mock(side_effect=OSError("Boom")) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), ) as remote, patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", return_value=mock_remotews, ) as remotews: result = await hass.config_entries.flow.async_init( @@ -1314,7 +1314,7 @@ async def test_form_reauth_websocket_not_supported(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=WebSocketException, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 6b6aa429243..6beac1b65bc 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -80,7 +80,7 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError ), patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", side_effect=OSError, ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index ddc28097de4..4c324abe4d8 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -2,13 +2,14 @@ import asyncio from datetime import datetime, timedelta import logging -from unittest.mock import DEFAULT as DEFAULT_MOCK, Mock, call, patch +from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch import pytest from samsungctl import exceptions -from samsungtvws import SamsungTVWS +from samsungtvws.async_remote import SamsungTVWSAsyncRemote from samsungtvws.exceptions import ConnectionFailure -from websocket import WebSocketException +from samsungtvws.remote import ChannelEmitCommand, SendRemoteKey +from websockets.exceptions import WebSocketException from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( @@ -159,13 +160,13 @@ async def test_setup_without_turnon(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remotews") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" - - with patch("homeassistant.components.samsungtv.bridge.SamsungTVWS") as remote_class: - remote = Mock(SamsungTVWS) - remote.__enter__ = Mock(return_value=remote) - remote.__exit__ = Mock() + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" + ) as remote_class: + 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 @@ -209,10 +210,12 @@ async def test_setup_websocket_2( "networkType": "wireless", }, } - with patch("homeassistant.components.samsungtv.bridge.SamsungTVWS") as remote_class: - remote = Mock(SamsungTVWS) - remote.__enter__ = Mock(return_value=remote) - remote.__exit__ = Mock() + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" + ) as remote_class: + 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 @@ -228,8 +231,7 @@ async def test_setup_websocket_2( state = hass.states.get(entity_id) assert state - assert remote_class.call_count == 2 - assert remote_class.call_args_list[0] == call(**MOCK_CALLS_WS) + remote_class.assert_called_once_with(**MOCK_CALLS_WS) @pytest.mark.usefixtures("remote") @@ -274,7 +276,8 @@ async def test_update_off_ws( state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - remotews.open = Mock(side_effect=WebSocketException("Boom")) + remotews.start_listening = Mock(side_effect=WebSocketException("Boom")) + remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -322,7 +325,9 @@ async def test_update_connection_failure( ): await setup_samsungtv(hass, MOCK_CONFIGWS) - with patch.object(remotews, "open", side_effect=ConnectionFailure("Boom")): + with patch.object( + remotews, "start_listening", side_effect=ConnectionFailure("Boom") + ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -454,7 +459,7 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" await setup_samsungtv(hass, MOCK_CONFIGWS) - remotews.send_key = Mock(side_effect=WebSocketException("Boom")) + remotews.send_command = Mock(side_effect=WebSocketException("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -465,7 +470,7 @@ async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" await setup_samsungtv(hass, MOCK_CONFIGWS) - remotews.send_key = Mock(side_effect=OSError("Boom")) + remotews.send_command = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -572,18 +577,22 @@ async def test_turn_off_websocket(hass: HomeAssistant, remotews: Mock) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): await setup_samsungtv(hass, MOCK_CONFIGWS) + remotews.send_command.reset_mock() + assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remotews.send_key.call_count == 1 - assert remotews.send_key.call_args_list == [call("KEY_POWER")] + assert remotews.send_command.call_count == 1 + command = remotews.send_command.call_args_list[0].args[0] + assert isinstance(command, SendRemoteKey) + assert command.params["DataOfCmd"] == "KEY_POWER" assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key not called - assert remotews.send_key.call_count == 1 + assert remotews.send_command.call_count == 1 async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: @@ -911,6 +920,7 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for play_media.""" await setup_samsungtv(hass, MOCK_CONFIGWS) + remotews.send_command.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -922,18 +932,24 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: }, True, ) - assert remotews.run_app.call_count == 1 - assert remotews.run_app.call_args_list == [call("3201608010191")] + assert remotews.send_command.call_count == 1 + command = remotews.send_command.call_args_list[0].args[0] + assert isinstance(command, ChannelEmitCommand) + assert command.params["data"]["appId"] == "3201608010191" async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for select_source.""" await setup_samsungtv(hass, MOCK_CONFIGWS) + remotews.send_command.reset_mock() + assert await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Deezer"}, True, ) - assert remotews.run_app.call_count == 1 - assert remotews.run_app.call_args_list == [call("3201608010191")] + assert remotews.send_command.call_count == 1 + command = remotews.send_command.call_args_list[0].args[0] + assert isinstance(command, ChannelEmitCommand) + assert command.params["data"]["appId"] == "3201608010191"