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
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__()

View File

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

View File

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

View File

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