Adjust SamsungTV abstraction layer (#67216)

Co-authored-by: epenet <epenet@users.noreply.github.com>
This commit is contained in:
epenet 2022-02-25 17:15:45 +01:00 committed by GitHub
parent c62a3c4f0d
commit 715d7f70f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 171 additions and 93 deletions

View File

@ -39,7 +39,6 @@ from .const import (
LEGACY_PORT, LEGACY_PORT,
LOGGER, LOGGER,
METHOD_LEGACY, METHOD_LEGACY,
METHOD_WEBSOCKET,
) )
@ -134,7 +133,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def stop_bridge(event: Event) -> None: async def stop_bridge(event: Event) -> None:
"""Stop SamsungTV bridge connection.""" """Stop SamsungTV bridge connection."""
await bridge.async_stop() LOGGER.debug("Stopping SamsungTVBridge %s", bridge.host)
await bridge.async_close_remote()
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge)
@ -149,11 +149,11 @@ async def _async_create_bridge_with_updated_data(
hass: HomeAssistant, entry: ConfigEntry hass: HomeAssistant, entry: ConfigEntry
) -> SamsungTVLegacyBridge | SamsungTVWSBridge: ) -> SamsungTVLegacyBridge | SamsungTVWSBridge:
"""Create a bridge object and update any missing data in the config entry.""" """Create a bridge object and update any missing data in the config entry."""
updated_data = {} updated_data: dict[str, str | int] = {}
host = entry.data[CONF_HOST] host: str = entry.data[CONF_HOST]
port = entry.data.get(CONF_PORT) port: int | None = entry.data.get(CONF_PORT)
method = entry.data.get(CONF_METHOD) method: str | None = entry.data.get(CONF_METHOD)
info = None info: dict[str, Any] | None = None
if not port or not method: if not port or not method:
if method == METHOD_LEGACY: if method == METHOD_LEGACY:
@ -162,7 +162,7 @@ async def _async_create_bridge_with_updated_data(
# When we imported from yaml we didn't setup the method # When we imported from yaml we didn't setup the method
# because we didn't know it # because we didn't know it
port, method, info = await async_get_device_info(hass, None, host) port, method, info = await async_get_device_info(hass, None, host)
if not port: if not port or not method:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"Failed to determine connection method, make sure the device is on." "Failed to determine connection method, make sure the device is on."
) )
@ -172,8 +172,8 @@ async def _async_create_bridge_with_updated_data(
bridge = _async_get_device_bridge(hass, {**entry.data, **updated_data}) bridge = _async_get_device_bridge(hass, {**entry.data, **updated_data})
mac = entry.data.get(CONF_MAC) mac: str | None = entry.data.get(CONF_MAC)
if not mac and bridge.method == METHOD_WEBSOCKET: if not mac:
if info: if info:
mac = mac_from_device_info(info) mac = mac_from_device_info(info)
else: else:
@ -197,7 +197,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
await hass.data[DOMAIN][entry.entry_id].async_stop() bridge: SamsungTVBridge = hass.data[DOMAIN][entry.entry_id]
LOGGER.debug("Stopping SamsungTVBridge %s", bridge.host)
await bridge.async_close_remote()
return unload_ok return unload_ok

View File

@ -19,7 +19,6 @@ from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_PORT, CONF_PORT,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_TOKEN,
) )
from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
@ -67,7 +66,7 @@ async def async_get_device_info(
bridge = SamsungTVBridge.get_bridge(hass, METHOD_LEGACY, host, LEGACY_PORT) bridge = SamsungTVBridge.get_bridge(hass, METHOD_LEGACY, host, LEGACY_PORT)
result = await bridge.async_try_connect() result = await bridge.async_try_connect()
if result in (RESULT_SUCCESS, RESULT_AUTH_MISSING): if result in (RESULT_SUCCESS, RESULT_AUTH_MISSING):
return LEGACY_PORT, METHOD_LEGACY, None return LEGACY_PORT, METHOD_LEGACY, await bridge.async_device_info()
return None, None, None return None, None, None
@ -97,7 +96,6 @@ class SamsungTVBridge(ABC):
self.method = method self.method = method
self.host = host self.host = host
self.token: str | None = None self.token: str | None = None
self._remote: Remote | None = None
self._reauth_callback: CALLBACK_TYPE | None = None self._reauth_callback: CALLBACK_TYPE | None = None
self._new_token_callback: CALLBACK_TYPE | None = None self._new_token_callback: CALLBACK_TYPE | None = None
@ -125,66 +123,17 @@ class SamsungTVBridge(ABC):
async def async_get_app_list(self) -> dict[str, str] | None: async def async_get_app_list(self) -> dict[str, str] | None:
"""Get installed app list.""" """Get installed app list."""
@abstractmethod
async def async_is_on(self) -> bool: async def async_is_on(self) -> bool:
"""Tells if the TV is on.""" """Tells if the TV is on."""
if self._remote is not None:
await self.async_close_remote()
try:
remote = await self.hass.async_add_executor_job(self._get_remote)
return remote is not None
except (
UnhandledResponse,
AccessDenied,
ConnectionFailure,
):
# We got a response so it's working.
return True
except OSError:
# Different reasons, e.g. hostname not resolveable
return False
@abstractmethod
async def async_send_key(self, key: str, key_type: str | None = None) -> None: async def async_send_key(self, key: str, key_type: str | None = None) -> None:
"""Send a key to the tv and handles exceptions.""" """Send a key to the tv and handles exceptions."""
try:
# recreate connection if connection was dead
retry_count = 1
for _ in range(retry_count + 1):
try:
await self._async_send_key(key, key_type)
break
except (
ConnectionClosed,
BrokenPipeError,
WebSocketException,
):
# BrokenPipe can occur when the commands is sent to fast
# WebSocketException can occur when timed out
self._remote = None
except (UnhandledResponse, AccessDenied):
# We got a response so it's on.
LOGGER.debug("Failed sending command %s", key, exc_info=True)
except OSError:
# Different reasons, e.g. hostname not resolveable
pass
@abstractmethod @abstractmethod
async def _async_send_key(self, key: str, key_type: str | None = None) -> None:
"""Send the key."""
@abstractmethod
def _get_remote(self, avoid_open: bool = False) -> Remote | SamsungTVWS:
"""Get Remote object."""
async def async_close_remote(self) -> None: async def async_close_remote(self) -> None:
"""Close remote object.""" """Close remote object."""
try:
if self._remote is not None:
# Close the current remote connection
await self.hass.async_add_executor_job(self._remote.close)
self._remote = None
except OSError:
LOGGER.debug("Could not establish connection")
def _notify_reauth_callback(self) -> None: def _notify_reauth_callback(self) -> None:
"""Notify access denied callback.""" """Notify access denied callback."""
@ -214,6 +163,7 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
CONF_PORT: None, CONF_PORT: None,
CONF_TIMEOUT: 1, CONF_TIMEOUT: 1,
} }
self._remote: Remote | None = None
async def async_mac_from_device(self) -> None: async def async_mac_from_device(self) -> None:
"""Try to fetch the mac address of the TV.""" """Try to fetch the mac address of the TV."""
@ -223,6 +173,21 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
"""Get installed app list.""" """Get installed app list."""
return {} 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)
def _is_on(self) -> bool:
"""Tells if the TV is on."""
if self._remote is not None:
self._close_remote()
try:
return self._get_remote() is not None
except (UnhandledResponse, AccessDenied):
# We got a response so it's working.
return True
async def async_try_connect(self) -> str: async def async_try_connect(self) -> str:
"""Try to connect to the Legacy TV.""" """Try to connect to the Legacy TV."""
return await self.hass.async_add_executor_job(self._try_connect) return await self.hass.async_add_executor_job(self._try_connect)
@ -258,7 +223,7 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
"""Try to gather infos of this device.""" """Try to gather infos of this device."""
return None return None
def _get_remote(self, avoid_open: bool = False) -> Remote: def _get_remote(self) -> Remote:
"""Create or return a remote control instance.""" """Create or return a remote control instance."""
if self._remote is None: if self._remote is None:
# We need to create a new instance to reconnect. # We need to create a new instance to reconnect.
@ -276,19 +241,43 @@ class SamsungTVLegacyBridge(SamsungTVBridge):
pass pass
return self._remote return self._remote
async def _async_send_key(self, key: str, key_type: str | None = None) -> None: async def async_send_key(self, key: str, key_type: str | None = None) -> None:
"""Send the key using legacy protocol.""" """Send the key using legacy protocol."""
return await self.hass.async_add_executor_job(self._send_key, key) await self.hass.async_add_executor_job(self._send_key, key)
def _send_key(self, key: str) -> None: def _send_key(self, key: str) -> None:
"""Send the key using legacy protocol.""" """Send the key using legacy protocol."""
if remote := self._get_remote(): try:
remote.control(key) # recreate connection if connection was dead
retry_count = 1
for _ in range(retry_count + 1):
try:
if remote := self._get_remote():
remote.control(key)
break
except (ConnectionClosed, BrokenPipeError):
# BrokenPipe can occur when the commands is sent to fast
self._remote = None
except (UnhandledResponse, AccessDenied):
# We got a response so it's on.
LOGGER.debug("Failed sending command %s", key, exc_info=True)
except OSError:
# Different reasons, e.g. hostname not resolveable
pass
async def async_stop(self) -> None: async def async_close_remote(self) -> None:
"""Stop Bridge.""" """Close remote object."""
LOGGER.debug("Stopping SamsungTVLegacyBridge") await self.hass.async_add_executor_job(self._close_remote)
await self.async_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()
self._remote = None
except OSError:
LOGGER.debug("Could not establish connection")
class SamsungTVWSBridge(SamsungTVBridge): class SamsungTVWSBridge(SamsungTVBridge):
@ -306,6 +295,7 @@ class SamsungTVWSBridge(SamsungTVBridge):
super().__init__(hass, method, host, port) super().__init__(hass, method, host, port)
self.token = token self.token = token
self._app_list: dict[str, str] | None = None self._app_list: dict[str, str] | None = None
self._remote: SamsungTVWS | None = None
async def async_mac_from_device(self) -> str | None: async def async_mac_from_device(self) -> str | None:
"""Try to fetch the mac address of the TV.""" """Try to fetch the mac address of the TV."""
@ -328,6 +318,17 @@ class SamsungTVWSBridge(SamsungTVBridge):
return 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
async def async_try_connect(self) -> str: async def async_try_connect(self) -> str:
"""Try to connect to the Websocket TV.""" """Try to connect to the Websocket TV."""
return await self.hass.async_add_executor_job(self._try_connect) return await self.hass.async_add_executor_job(self._try_connect)
@ -356,8 +357,6 @@ class SamsungTVWSBridge(SamsungTVBridge):
) as remote: ) as remote:
remote.open("samsung.remote.control") remote.open("samsung.remote.control")
self.token = remote.token self.token = remote.token
if self.token is None:
config[CONF_TOKEN] = "*****"
LOGGER.debug("Working config: %s", config) LOGGER.debug("Working config: %s", config)
return RESULT_SUCCESS return RESULT_SUCCESS
except WebSocketException as err: except WebSocketException as err:
@ -375,29 +374,47 @@ class SamsungTVWSBridge(SamsungTVBridge):
return RESULT_CANNOT_CONNECT return RESULT_CANNOT_CONNECT
async def async_device_info(self) -> dict[str, Any] | None: async def async_device_info(self) -> dict[str, Any] | None:
"""Try to gather infos of this TV."""
return await self.hass.async_add_executor_job(self._device_info)
def _device_info(self) -> dict[str, Any] | None:
"""Try to gather infos of this TV.""" """Try to gather infos of this TV."""
if remote := self._get_remote(avoid_open=True): if remote := self._get_remote(avoid_open=True):
with contextlib.suppress(HttpApiError, RequestsTimeout): with contextlib.suppress(HttpApiError, RequestsTimeout):
device_info: dict[str, Any] = await self.hass.async_add_executor_job( device_info: dict[str, Any] = remote.rest_device_info()
remote.rest_device_info
)
return device_info return device_info
return None return None
async def _async_send_key(self, key: str, key_type: str | None = None) -> None: async def async_send_key(self, key: str, key_type: str | None = None) -> None:
"""Send the key using websocket protocol.""" """Send the key using websocket protocol."""
return await self.hass.async_add_executor_job(self._send_key, key, key_type) 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: def _send_key(self, key: str, key_type: str | None = None) -> None:
"""Send the key using websocket protocol.""" """Send the key using websocket protocol."""
if key == "KEY_POWEROFF": if key == "KEY_POWEROFF":
key = "KEY_POWER" key = "KEY_POWER"
if remote := self._get_remote(): try:
if key_type == "run_app": # recreate connection if connection was dead
remote.run_app(key) retry_count = 1
else: for _ in range(retry_count + 1):
remote.send_key(key) try:
if remote := self._get_remote():
if key_type == "run_app":
remote.run_app(key)
else:
remote.send_key(key)
break
except (
BrokenPipeError,
WebSocketException,
):
# BrokenPipe can occur when the commands is sent to fast
# WebSocketException can occur when timed out
self._remote = None
except OSError:
# Different reasons, e.g. hostname not resolveable
pass
def _get_remote(self, avoid_open: bool = False) -> SamsungTVWS: def _get_remote(self, avoid_open: bool = False) -> SamsungTVWS:
"""Create or return a remote control instance.""" """Create or return a remote control instance."""
@ -437,7 +454,16 @@ class SamsungTVWSBridge(SamsungTVBridge):
self._notify_new_token_callback() self._notify_new_token_callback()
return self._remote return self._remote
async def async_stop(self) -> None: async def async_close_remote(self) -> None:
"""Stop Bridge.""" """Close remote object."""
LOGGER.debug("Stopping SamsungTVWSBridge") await self.hass.async_add_executor_job(self._close_remote)
await self.async_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()
self._remote = None
except OSError:
LOGGER.debug("Could not establish connection")

View File

@ -274,6 +274,26 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None:
assert state.state == STATE_OFF assert state.state == STATE_OFF
async def test_update_off_ws(
hass: HomeAssistant, remotews: Mock, mock_now: datetime
) -> None:
"""Testing update tv off."""
await setup_samsungtv(hass, MOCK_CONFIGWS)
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_ON
remotews.open = Mock(side_effect=WebSocketException("Boom"))
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_OFF
@pytest.mark.usefixtures("remote") @pytest.mark.usefixtures("remote")
async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None:
"""Testing update tv access denied exception.""" """Testing update tv access denied exception."""
@ -440,10 +460,21 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) ->
assert state.state == STATE_ON assert state.state == STATE_ON
async def test_send_key_websocketexception(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None:
"""Testing unhandled response exception.""" """Testing unhandled response exception."""
await setup_samsungtv(hass, MOCK_CONFIG) await setup_samsungtv(hass, MOCK_CONFIGWS)
remote.control = Mock(side_effect=WebSocketException("Boom")) remotews.send_key = Mock(side_effect=WebSocketException("Boom"))
assert await hass.services.async_call(
DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True
)
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_ON
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"))
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True
) )
@ -557,6 +588,12 @@ async def test_turn_off_websocket(hass: HomeAssistant, remotews: Mock) -> None:
assert remotews.send_key.call_count == 1 assert remotews.send_key.call_count == 1
assert remotews.send_key.call_args_list == [call("KEY_POWER")] assert remotews.send_key.call_args_list == [call("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
async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None:
"""Test for turn_off.""" """Test for turn_off."""
@ -582,6 +619,19 @@ async def test_turn_off_os_error(
assert "Could not establish connection" in caplog.text assert "Could not establish connection" in caplog.text
async def test_turn_off_ws_os_error(
hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture
) -> None:
"""Test for turn_off with OSError."""
caplog.set_level(logging.DEBUG)
await setup_samsungtv(hass, MOCK_CONFIGWS)
remotews.close = Mock(side_effect=OSError("BOOM"))
assert await hass.services.async_call(
DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
)
assert "Could not establish connection" in caplog.text
async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None:
"""Test for volume_up.""" """Test for volume_up."""
await setup_samsungtv(hass, MOCK_CONFIG) await setup_samsungtv(hass, MOCK_CONFIG)