From 8eb2e131e5e1d3e1f0e1eb68ce05df081e5ed8dc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 29 Mar 2022 00:23:38 +0200 Subject: [PATCH] Use DmrDevice to communicate with SamsungTV (#68777) Co-authored-by: epenet --- .../components/samsungtv/media_player.py | 127 ++++++++++++------ tests/components/samsungtv/__init__.py | 24 ---- tests/components/samsungtv/conftest.py | 44 ++++++ .../components/samsungtv/test_media_player.py | 124 ++++++++++++----- 4 files changed, 222 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index c20674efdfd..33a7c44d45d 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -2,15 +2,22 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Coroutine, Sequence import contextlib from datetime import datetime, timedelta from typing import Any -from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.client import UpnpDevice, UpnpService +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester +from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory -from async_upnp_client.exceptions import UpnpActionResponseError, UpnpConnectionError +from async_upnp_client.exceptions import ( + UpnpActionResponseError, + UpnpConnectionError, + UpnpError, + UpnpResponseError, +) +from async_upnp_client.profiles.dlna import DmrDevice +from async_upnp_client.utils import async_get_local_ip import voluptuous as vol from wakeonlan import send_magic_packet @@ -35,7 +42,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -54,7 +61,6 @@ from .const import ( DEFAULT_NAME, DOMAIN, LOGGER, - UPNP_SVC_RENDERING_CONTROL, ) SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -114,7 +120,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._config_entry = config_entry self._host: str | None = config_entry.data[CONF_HOST] self._mac: str | None = config_entry.data.get(CONF_MAC) - self._ssdp_rendering_control_location = config_entry.data.get( + self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) self._on_script = on_script @@ -157,7 +163,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_app_list_callback(self._app_list_callback) - self._upnp_device: UpnpDevice | None = None + self._dmr_device: DmrDevice | None = None + self._upnp_server: AiohttpNotifyServer | None = None def _update_sources(self) -> None: self._attr_source_list = list(SOURCES) @@ -185,6 +192,10 @@ class SamsungTVDevice(MediaPlayerEntity): ) ) + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + await self._async_shutdown_dmr() + async def async_update(self) -> None: """Update state of device.""" if self._auth_failed or self.hass.is_stopping: @@ -204,24 +215,22 @@ class SamsungTVDevice(MediaPlayerEntity): if not self._app_list_event.is_set(): startup_tasks.append(self._async_startup_app_list()) - if not self._upnp_device and self._ssdp_rendering_control_location: - startup_tasks.append(self._async_startup_upnp()) + if not self._dmr_device and self._ssdp_rendering_control_location: + startup_tasks.append(self._async_startup_dmr()) if startup_tasks: await asyncio.gather(*startup_tasks) - if not (service := self._get_upnp_service()): + self._update_from_upnp() + + @callback + def _update_from_upnp(self) -> None: + if (dmr_device := self._dmr_device) is None: return - get_volume, get_mute = await asyncio.gather( - service.action("GetVolume").async_call(InstanceID=0, Channel="Master"), - service.action("GetMute").async_call(InstanceID=0, Channel="Master"), - ) - LOGGER.debug("Upnp GetVolume on %s: %s", self._host, get_volume) - if (volume_level := get_volume.get("CurrentVolume")) is not None: - self._attr_volume_level = volume_level / 100 - LOGGER.debug("Upnp GetMute on %s: %s", self._host, get_mute) - if (is_muted := get_mute.get("CurrentMute")) is not None: + if (volume_level := dmr_device.volume_level) is not None: + self._attr_volume_level = volume_level + if (is_muted := dmr_device.is_volume_muted) is not None: self._attr_is_volume_muted = is_muted async def _async_startup_app_list(self) -> None: @@ -239,33 +248,66 @@ class SamsungTVDevice(MediaPlayerEntity): "Failed to load app list from %s: %s", self._host, err.__repr__() ) - async def _async_startup_upnp(self) -> None: + async def _async_startup_dmr(self) -> None: assert self._ssdp_rendering_control_location is not None - if self._upnp_device is None: + if self._dmr_device is None: session = async_get_clientsession(self.hass) upnp_requester = AiohttpSessionRequester(session) upnp_factory = UpnpFactory(upnp_requester) + upnp_device: UpnpDevice | None = None with contextlib.suppress(UpnpConnectionError): - self._upnp_device = await upnp_factory.async_create_device( + upnp_device = await upnp_factory.async_create_device( self._ssdp_rendering_control_location ) - - def _get_upnp_service(self, log: bool = False) -> UpnpService | None: - if self._upnp_device is None: - if log: - LOGGER.info("Upnp services are not available on %s", self._host) - return None - - if service := self._upnp_device.services.get(UPNP_SVC_RENDERING_CONTROL): - return service - - if log: - LOGGER.info( - "Upnp service %s is not available on %s", - UPNP_SVC_RENDERING_CONTROL, - self._host, + if not upnp_device: + return + _, event_ip = await async_get_local_ip( + self._ssdp_rendering_control_location, self.hass.loop ) - return None + source = (event_ip or "0.0.0.0", 0) + self._upnp_server = AiohttpNotifyServer( + requester=upnp_requester, + source=source, + callback_url=None, + loop=self.hass.loop, + ) + await self._upnp_server.async_start_server() + self._dmr_device = DmrDevice(upnp_device, self._upnp_server.event_handler) + + try: + self._dmr_device.on_event = self._on_upnp_event + await self._dmr_device.async_subscribe_services(auto_resubscribe=True) + except UpnpResponseError as err: + # Device rejected subscription request. This is OK, variables + # will be polled instead. + LOGGER.debug("Device rejected subscription: %r", err) + except UpnpError as err: + # Don't leave the device half-constructed + self._dmr_device.on_event = None + self._dmr_device = None + await self._upnp_server.async_stop_server() + self._upnp_server = None + LOGGER.debug("Error while subscribing during device connect: %r", err) + raise + + async def _async_shutdown_dmr(self) -> None: + """Handle removal.""" + if (dmr_device := self._dmr_device) is not None: + self._dmr_device = None + dmr_device.on_event = None + await dmr_device.async_unsubscribe_services() + + if (upnp_server := self._upnp_server) is not None: + self._upnp_server = None + await upnp_server.async_stop_server() + + def _on_upnp_event( + self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] + ) -> None: + """State variable(s) changed, let home-assistant know.""" + self._update_from_upnp() + + self.async_write_ha_state() async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" @@ -308,12 +350,11 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" - if not (service := self._get_upnp_service(log=True)): + if (dmr_device := self._dmr_device) is None: + LOGGER.info("Upnp services are not available on %s", self._host) return try: - await service.action("SetVolume").async_call( - InstanceID=0, Channel="Master", DesiredVolume=int(volume * 100) - ) + await dmr_device.async_set_volume_level(volume) except UpnpActionResponseError as err: LOGGER.warning( "Unable to set volume level on %s: %s", self._host, err.__repr__() diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 516aa5d8c95..53e47f6170b 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -2,9 +2,6 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import Mock - -from async_upnp_client.client import UpnpAction, UpnpService from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDOWN from homeassistant.config_entries import ConfigEntry @@ -36,24 +33,3 @@ async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> Config await hass.async_block_till_done() return entry - - -def upnp_get_action_mock(device: Mock, service_type: str, action: str) -> Mock: - """Get or Add UpnpService/UpnpAction to UpnpDevice mock.""" - upnp_service: Mock | None - if (upnp_service := device.services.get(service_type)) is None: - upnp_service = Mock(UpnpService) - upnp_service.actions = {} - - def _get_action(action: str): - return upnp_service.actions.get(action) - - upnp_service.action.side_effect = _get_action - device.services[service_type] = upnp_service - - upnp_action: Mock | None - if (upnp_action := upnp_service.actions.get(action)) is None: - upnp_action = Mock(UpnpAction) - upnp_service.actions[action] = upnp_action - - return upnp_action diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index e4ef0666423..0f3bc53905a 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import datetime +from socket import AddressFamily from typing import Any from unittest.mock import AsyncMock, Mock, patch from async_upnp_client.client import UpnpDevice +from async_upnp_client.event_handler import UpnpEventHandler from async_upnp_client.exceptions import UpnpConnectionError import pytest from samsungctl import Remote @@ -39,6 +41,16 @@ def samsungtv_mock_get_source_ip(mock_get_source_ip): """Mock network util's async_get_source_ip.""" +@pytest.fixture(autouse=True) +def samsungtv_mock_async_get_local_ip(): + """Mock upnp util's async_get_local_ip.""" + with patch( + "homeassistant.components.samsungtv.media_player.async_get_local_ip", + return_value=(AddressFamily.AF_INET, "10.10.10.10"), + ): + yield + + @pytest.fixture(autouse=True) def fake_host_fixture() -> None: """Patch gethostbyname.""" @@ -78,6 +90,38 @@ async def upnp_device_fixture(upnp_factory: Mock) -> Mock: yield upnp_device +@pytest.fixture(name="dmr_device") +async def dmr_device_fixture(upnp_device: Mock) -> Mock: + """Patch async_upnp_client.""" + with patch( + "homeassistant.components.samsungtv.media_player.DmrDevice", + autospec=True, + ) as dmr_device_class: + dmr_device: Mock = dmr_device_class.return_value + dmr_device.volume_level = 0.44 + dmr_device.is_volume_muted = False + dmr_device.on_event = None + + def _raise_event(service, state_variables): + if dmr_device.on_event: + dmr_device.on_event(service, state_variables) + + dmr_device.raise_event = _raise_event + yield dmr_device + + +@pytest.fixture(name="upnp_notify_server") +async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: + """Patch async_upnp_client.""" + with patch( + "homeassistant.components.samsungtv.media_player.AiohttpNotifyServer", + autospec=True, + ) as notify_server_class: + notify_server: Mock = notify_server_class.return_value + notify_server.event_handler = Mock(UpnpEventHandler) + yield notify_server + + @pytest.fixture(name="remote") def remote_fixture() -> Mock: """Patch the samsungctl Remote.""" diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index f4bb40845a8..7f9f5936f11 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -4,7 +4,11 @@ from datetime import datetime, timedelta import logging from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch -from async_upnp_client.exceptions import UpnpActionResponseError +from async_upnp_client.exceptions import ( + UpnpActionResponseError, + UpnpError, + UpnpResponseError, +) import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -42,10 +46,7 @@ from homeassistant.components.samsungtv.const import ( METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, ) -from homeassistant.components.samsungtv.media_player import ( - SUPPORT_SAMSUNGTV, - UPNP_SVC_RENDERING_CONTROL, -) +from homeassistant.components.samsungtv.media_player import SUPPORT_SAMSUNGTV from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -80,11 +81,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import ( - async_wait_config_entry_reload, - setup_samsungtv_entry, - upnp_get_action_mock, -) +from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( MOCK_ENTRYDATA_ENCRYPTED_WS, SAMPLE_DEVICE_INFO_FRAME, @@ -1329,39 +1326,30 @@ async def test_websocket_unsupported_remote_control( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") async def test_volume_control_upnp( - hass: HomeAssistant, upnp_device: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, dmr_device: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for Upnp volume control.""" - upnp_get_volume = upnp_get_action_mock( - upnp_device, UPNP_SVC_RENDERING_CONTROL, "GetVolume" - ) - upnp_get_volume.async_call.return_value = {"CurrentVolume": 44} - - upnp_get_mute = upnp_get_action_mock( - upnp_device, UPNP_SVC_RENDERING_CONTROL, "GetMute" - ) - upnp_get_mute.async_call.return_value = {"CurrentMute": False} - await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) - upnp_get_volume.async_call.assert_called_once() - upnp_get_mute.async_call.assert_called_once() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.44 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False # Upnp action succeeds - upnp_set_volume = upnp_get_action_mock( - upnp_device, UPNP_SVC_RENDERING_CONTROL, "SetVolume" - ) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, True, ) + dmr_device.async_set_volume_level.assert_called_once_with(0.5) assert "Unable to set volume level on" not in caplog.text # Upnp action failed - upnp_set_volume.async_call.side_effect = UpnpActionResponseError( + dmr_device.async_set_volume_level.reset_mock() + dmr_device.async_set_volume_level.side_effect = UpnpActionResponseError( status=500, error_code=501, error_desc="Action Failed" ) assert await hass.services.async_call( @@ -1370,6 +1358,7 @@ async def test_volume_control_upnp( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, True, ) + dmr_device.async_set_volume_level.assert_called_once_with(0.6) assert "Unable to set volume level on" in caplog.text @@ -1390,7 +1379,7 @@ async def test_upnp_not_available( assert "Upnp services are not available" in caplog.text -@pytest.mark.usefixtures("remotews", "upnp_device", "rest_api") +@pytest.mark.usefixtures("remotews", "rest_api", "upnp_factory") async def test_upnp_missing_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1404,4 +1393,79 @@ async def test_upnp_missing_service( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, True, ) - assert f"Upnp service {UPNP_SVC_RENDERING_CONTROL} is not available" in caplog.text + assert "Upnp services are not available" in caplog.text + + +@pytest.mark.usefixtures("remotews", "rest_api") +async def test_upnp_shutdown( + hass: HomeAssistant, + dmr_device: Mock, + upnp_notify_server: Mock, +) -> None: + """Ensure that Upnp cleanup takes effect.""" + entry = await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + assert await entry.async_unload(hass) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + dmr_device.async_unsubscribe_services.assert_called_once() + upnp_notify_server.async_stop_server.assert_called_once() + + +@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") +async def test_upnp_subscribe_events(hass: HomeAssistant, dmr_device: Mock) -> None: + """Test for Upnp event feedback.""" + await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.44 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + + # DMR Devices gets updated, and raise event + dmr_device.volume_level = 0 + dmr_device.is_volume_muted = True + dmr_device.raise_event(None, None) + + # State gets updated without the need to wait for next update + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0 + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True + + +@pytest.mark.usefixtures("remotews", "rest_api") +async def test_upnp_subscribe_events_upnperror( + hass: HomeAssistant, + dmr_device: Mock, + upnp_notify_server: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for failure to subscribe Upnp services.""" + with patch.object(dmr_device, "async_subscribe_services", side_effect=UpnpError): + await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) + + upnp_notify_server.async_stop_server.assert_called_once() + assert "Error while subscribing during device connect" in caplog.text + + +@pytest.mark.usefixtures("remotews", "rest_api") +async def test_upnp_subscribe_events_upnpresponseerror( + hass: HomeAssistant, + dmr_device: Mock, + upnp_notify_server: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for failure to subscribe Upnp services.""" + with patch.object( + dmr_device, + "async_subscribe_services", + side_effect=UpnpResponseError(status=501), + ): + await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) + + upnp_notify_server.async_stop_server.assert_not_called() + assert "Device rejected subscription" in caplog.text