1
mirror of https://github.com/home-assistant/core synced 2024-08-06 09:34:49 +02:00

Implement async websocket in samsungtv (#67127)

Co-authored-by: epenet <epenet@users.noreply.github.com>
This commit is contained in:
epenet 2022-03-02 20:30:33 +01:00 committed by GitHub
parent b245ba6d57
commit b8861578ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 111 additions and 103 deletions

View File

@ -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."""

View File

@ -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")

View File

@ -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

View File

@ -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(

View File

@ -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",

View File

@ -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"