Use DmrDevice to communicate with SamsungTV (#68777)

Co-authored-by: epenet <epenet@users.noreply.github.com>
This commit is contained in:
epenet 2022-03-29 00:23:38 +02:00 committed by GitHub
parent d0e5e51863
commit 8eb2e131e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 222 additions and 97 deletions

View File

@ -2,15 +2,22 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine from collections.abc import Coroutine, Sequence
import contextlib import contextlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
from async_upnp_client.client import UpnpDevice, UpnpService from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
from async_upnp_client.client_factory import UpnpFactory 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 import voluptuous as vol
from wakeonlan import send_magic_packet 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.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_OFF, STATE_ON 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 import entity_component
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -54,7 +61,6 @@ from .const import (
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
UPNP_SVC_RENDERING_CONTROL,
) )
SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}
@ -114,7 +120,7 @@ class SamsungTVDevice(MediaPlayerEntity):
self._config_entry = config_entry self._config_entry = config_entry
self._host: str | None = config_entry.data[CONF_HOST] self._host: str | None = config_entry.data[CONF_HOST]
self._mac: str | None = config_entry.data.get(CONF_MAC) 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 CONF_SSDP_RENDERING_CONTROL_LOCATION
) )
self._on_script = on_script self._on_script = on_script
@ -157,7 +163,8 @@ class SamsungTVDevice(MediaPlayerEntity):
self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_reauth_callback(self.access_denied)
self._bridge.register_app_list_callback(self._app_list_callback) 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: def _update_sources(self) -> None:
self._attr_source_list = list(SOURCES) 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: async def async_update(self) -> None:
"""Update state of device.""" """Update state of device."""
if self._auth_failed or self.hass.is_stopping: if self._auth_failed or self.hass.is_stopping:
@ -204,24 +215,22 @@ class SamsungTVDevice(MediaPlayerEntity):
if not self._app_list_event.is_set(): if not self._app_list_event.is_set():
startup_tasks.append(self._async_startup_app_list()) startup_tasks.append(self._async_startup_app_list())
if not self._upnp_device and self._ssdp_rendering_control_location: if not self._dmr_device and self._ssdp_rendering_control_location:
startup_tasks.append(self._async_startup_upnp()) startup_tasks.append(self._async_startup_dmr())
if startup_tasks: if startup_tasks:
await asyncio.gather(*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 return
get_volume, get_mute = await asyncio.gather( if (volume_level := dmr_device.volume_level) is not None:
service.action("GetVolume").async_call(InstanceID=0, Channel="Master"), self._attr_volume_level = volume_level
service.action("GetMute").async_call(InstanceID=0, Channel="Master"), if (is_muted := dmr_device.is_volume_muted) is not None:
)
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:
self._attr_is_volume_muted = is_muted self._attr_is_volume_muted = is_muted
async def _async_startup_app_list(self) -> None: 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__() "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 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) session = async_get_clientsession(self.hass)
upnp_requester = AiohttpSessionRequester(session) upnp_requester = AiohttpSessionRequester(session)
upnp_factory = UpnpFactory(upnp_requester) upnp_factory = UpnpFactory(upnp_requester)
upnp_device: UpnpDevice | None = None
with contextlib.suppress(UpnpConnectionError): 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 self._ssdp_rendering_control_location
) )
if not upnp_device:
def _get_upnp_service(self, log: bool = False) -> UpnpService | None: return
if self._upnp_device is None: _, event_ip = await async_get_local_ip(
if log: self._ssdp_rendering_control_location, self.hass.loop
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,
) )
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: async def _async_launch_app(self, app_id: str) -> None:
"""Send launch_app to the tv.""" """Send launch_app to the tv."""
@ -308,12 +350,11 @@ class SamsungTVDevice(MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level on the media player.""" """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 return
try: try:
await service.action("SetVolume").async_call( await dmr_device.async_set_volume_level(volume)
InstanceID=0, Channel="Master", DesiredVolume=int(volume * 100)
)
except UpnpActionResponseError as err: except UpnpActionResponseError as err:
LOGGER.warning( LOGGER.warning(
"Unable to set volume level on %s: %s", self._host, err.__repr__() "Unable to set volume level on %s: %s", self._host, err.__repr__()

View File

@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta 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.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDOWN
from homeassistant.config_entries import ConfigEntry 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() await hass.async_block_till_done()
return entry 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

View File

@ -3,10 +3,12 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import datetime from datetime import datetime
from socket import AddressFamily
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from async_upnp_client.client import UpnpDevice from async_upnp_client.client import UpnpDevice
from async_upnp_client.event_handler import UpnpEventHandler
from async_upnp_client.exceptions import UpnpConnectionError from async_upnp_client.exceptions import UpnpConnectionError
import pytest import pytest
from samsungctl import Remote 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.""" """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) @pytest.fixture(autouse=True)
def fake_host_fixture() -> None: def fake_host_fixture() -> None:
"""Patch gethostbyname.""" """Patch gethostbyname."""
@ -78,6 +90,38 @@ async def upnp_device_fixture(upnp_factory: Mock) -> Mock:
yield upnp_device 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") @pytest.fixture(name="remote")
def remote_fixture() -> Mock: def remote_fixture() -> Mock:
"""Patch the samsungctl Remote.""" """Patch the samsungctl Remote."""

View File

@ -4,7 +4,11 @@ from datetime import datetime, timedelta
import logging import logging
from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch 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 import pytest
from samsungctl import exceptions from samsungctl import exceptions
from samsungtvws.async_remote import SamsungTVWSAsyncRemote from samsungtvws.async_remote import SamsungTVWSAsyncRemote
@ -42,10 +46,7 @@ from homeassistant.components.samsungtv.const import (
METHOD_WEBSOCKET, METHOD_WEBSOCKET,
TIMEOUT_WEBSOCKET, TIMEOUT_WEBSOCKET,
) )
from homeassistant.components.samsungtv.media_player import ( from homeassistant.components.samsungtv.media_player import SUPPORT_SAMSUNGTV
SUPPORT_SAMSUNGTV,
UPNP_SVC_RENDERING_CONTROL,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -80,11 +81,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import ( from . import async_wait_config_entry_reload, setup_samsungtv_entry
async_wait_config_entry_reload,
setup_samsungtv_entry,
upnp_get_action_mock,
)
from .const import ( from .const import (
MOCK_ENTRYDATA_ENCRYPTED_WS, MOCK_ENTRYDATA_ENCRYPTED_WS,
SAMPLE_DEVICE_INFO_FRAME, SAMPLE_DEVICE_INFO_FRAME,
@ -1329,39 +1326,30 @@ async def test_websocket_unsupported_remote_control(
assert state.state == STATE_UNAVAILABLE 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( async def test_volume_control_upnp(
hass: HomeAssistant, upnp_device: Mock, caplog: pytest.LogCaptureFixture hass: HomeAssistant, dmr_device: Mock, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test for Upnp volume control.""" """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) 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 action succeeds
upnp_set_volume = upnp_get_action_mock(
upnp_device, UPNP_SVC_RENDERING_CONTROL, "SetVolume"
)
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
True, True,
) )
dmr_device.async_set_volume_level.assert_called_once_with(0.5)
assert "Unable to set volume level on" not in caplog.text assert "Unable to set volume level on" not in caplog.text
# Upnp action failed # 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" status=500, error_code=501, error_desc="Action Failed"
) )
assert await hass.services.async_call( 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}, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6},
True, True,
) )
dmr_device.async_set_volume_level.assert_called_once_with(0.6)
assert "Unable to set volume level on" in caplog.text 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 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( async def test_upnp_missing_service(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@ -1404,4 +1393,79 @@ async def test_upnp_missing_service(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6},
True, 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