dlna_dms fixes from code review (#67796)

This commit is contained in:
Michael Chisholm 2022-03-30 00:32:16 +11:00 committed by GitHub
parent bdb61e0222
commit 62aa7fe10e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 610 additions and 630 deletions

View File

@ -8,14 +8,22 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import LOGGER
from .const import CONF_SOURCE_ID, LOGGER
from .dms import get_domain_data
from .util import generate_source_id
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up DLNA DMS device from a config entry."""
LOGGER.debug("Setting up config entry: %s", entry.unique_id)
# Soft-migrate entry if it's missing data keys
if CONF_SOURCE_ID not in entry.data:
LOGGER.debug("Adding CONF_SOURCE_ID to entry %s", entry.data)
data = dict(entry.data)
data[CONF_SOURCE_ID] = generate_source_id(hass, entry.title)
hass.config_entries.async_update_entry(entry, data=data)
# Forward setup to this domain's data manager
return await get_domain_data(hass).async_setup_entry(entry)

View File

@ -13,17 +13,13 @@ from homeassistant import config_entries
from homeassistant.components import ssdp
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.exceptions import IntegrationError
from .const import DEFAULT_NAME, DOMAIN
from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN
from .util import generate_source_id
LOGGER = logging.getLogger(__name__)
class ConnectError(IntegrationError):
"""Error occurred when trying to connect to a device."""
class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a DLNA DMS config flow.
@ -32,7 +28,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
the DMS is an embedded device.
"""
VERSION = 1
VERSION = CONFIG_VERSION
def __init__(self) -> None:
"""Initialize flow."""
@ -50,7 +46,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None and (host := user_input.get(CONF_HOST)):
# User has chosen a device
discovery = self._discoveries[host]
await self._async_parse_discovery(discovery)
await self._async_parse_discovery(discovery, raise_on_progress=False)
return self._create_entry()
if not (discoveries := await self._async_get_discoveries()):
@ -100,8 +96,6 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Allow the user to confirm adding the device."""
LOGGER.debug("async_step_confirm: %s", user_input)
if user_input is not None:
return self._create_entry()
@ -111,17 +105,24 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
def _create_entry(self) -> FlowResult:
"""Create a config entry, assuming all required information is now known."""
LOGGER.debug(
"_async_create_entry: location: %s, USN: %s", self._location, self._usn
"_create_entry: name: %s, location: %s, USN: %s",
self._name,
self._location,
self._usn,
)
assert self._name
assert self._location
assert self._usn
data = {CONF_URL: self._location, CONF_DEVICE_ID: self._usn}
data = {
CONF_URL: self._location,
CONF_DEVICE_ID: self._usn,
CONF_SOURCE_ID: generate_source_id(self.hass, self._name),
}
return self.async_create_entry(title=self._name, data=data)
async def _async_parse_discovery(
self, discovery_info: ssdp.SsdpServiceInfo
self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True
) -> None:
"""Get required details from an SSDP discovery.
@ -140,7 +141,7 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._location = discovery_info.ssdp_location
self._usn = discovery_info.ssdp_usn
await self.async_set_unique_id(self._usn)
await self.async_set_unique_id(self._usn, raise_on_progress=raise_on_progress)
# Abort if already configured, but update the last-known location
self._abort_if_unique_id_configured(
@ -155,8 +156,6 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]:
"""Get list of unconfigured DLNA devices discovered by SSDP."""
LOGGER.debug("_get_discoveries")
# Get all compatible devices from ssdp's cache
discoveries: list[ssdp.SsdpServiceInfo] = []
for udn_st in DmsDevice.DEVICE_TYPES:

View File

@ -12,6 +12,9 @@ LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "dlna_dms"
DEFAULT_NAME: Final = "DLNA Media Server"
CONF_SOURCE_ID: Final = "source_id"
CONFIG_VERSION: Final = 1
SOURCE_SEP: Final = "/"
ROOT_OBJECT_ID: Final = "0"
PATH_SEP: Final = "/"

View File

@ -11,7 +11,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.client import UpnpRequester
from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.const import NotificationSubType
from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer
from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError
from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
from didl_lite import didl_lite
@ -26,9 +25,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.util import slugify
from .const import (
CONF_SOURCE_ID,
DLNA_BROWSE_FILTER,
DLNA_PATH_FILTER,
DLNA_RESOLVE_FILTER,
@ -51,10 +50,8 @@ class DlnaDmsData:
"""Storage class for domain global data."""
hass: HomeAssistant
lock: asyncio.Lock
requester: UpnpRequester
upnp_factory: UpnpFactory
event_handler: UpnpEventHandler
devices: dict[str, DmsDeviceSource] # Indexed by config_entry.unique_id
sources: dict[str, DmsDeviceSource] # Indexed by source_id
@ -64,69 +61,32 @@ class DlnaDmsData:
) -> None:
"""Initialize global data."""
self.hass = hass
self.lock = asyncio.Lock()
session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
self.requester = AiohttpSessionRequester(session, with_sleep=True)
self.upnp_factory = UpnpFactory(self.requester, non_strict=True)
# NOTE: event_handler is not actually used, and is only created to
# satisfy the DmsDevice.__init__ signature
self.event_handler = UpnpEventHandler(UpnpNotifyServer(), self.requester)
self.devices = {}
self.sources = {}
async def async_setup_entry(self, config_entry: ConfigEntry) -> bool:
"""Create a DMS device connection from a config entry."""
assert config_entry.unique_id
async with self.lock:
source_id = self._generate_source_id(config_entry.title)
device = DmsDeviceSource(self.hass, config_entry, source_id)
self.devices[config_entry.unique_id] = device
self.sources[device.source_id] = device
# Update the device when the associated config entry is modified
config_entry.async_on_unload(
config_entry.add_update_listener(self.async_update_entry)
)
device = DmsDeviceSource(self.hass, config_entry)
self.devices[config_entry.unique_id] = device
# source_id must be unique, which generate_source_id should guarantee.
# Ensure this is the case, for debugging purposes.
assert device.source_id not in self.sources
self.sources[device.source_id] = device
await device.async_added_to_hass()
return True
async def async_unload_entry(self, config_entry: ConfigEntry) -> bool:
"""Unload a config entry and disconnect the corresponding DMS device."""
assert config_entry.unique_id
async with self.lock:
device = self.devices.pop(config_entry.unique_id)
del self.sources[device.source_id]
device = self.devices.pop(config_entry.unique_id)
del self.sources[device.source_id]
await device.async_will_remove_from_hass()
return True
async def async_update_entry(
self, hass: HomeAssistant, config_entry: ConfigEntry
) -> None:
"""Update a DMS device when the config entry changes."""
assert config_entry.unique_id
async with self.lock:
device = self.devices[config_entry.unique_id]
# Update the source_id to match the new name
del self.sources[device.source_id]
device.source_id = self._generate_source_id(config_entry.title)
self.sources[device.source_id] = device
def _generate_source_id(self, name: str) -> str:
"""Generate a unique source ID.
Caller should hold self.lock when calling this method.
"""
source_id_base = slugify(name)
if source_id_base not in self.sources:
return source_id_base
tries = 1
while (suggested_source_id := f"{source_id_base}_{tries}") in self.sources:
tries += 1
return suggested_source_id
@callback
def get_domain_data(hass: HomeAssistant) -> DlnaDmsData:
@ -202,12 +162,6 @@ def catch_request_errors(
class DmsDeviceSource:
"""DMS Device wrapper, providing media files as a media_source."""
hass: HomeAssistant
config_entry: ConfigEntry
# Unique slug used for media-source URIs
source_id: str
# Last known URL for the device, used when adding this wrapper to hass to
# try to connect before SSDP has rediscovered it, or when SSDP discovery
# fails.
@ -222,13 +176,10 @@ class DmsDeviceSource:
# Track BOOTID in SSDP advertisements for device changes
_bootid: int | None = None
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, source_id: str
) -> None:
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize a DMS Source."""
self.hass = hass
self.config_entry = config_entry
self.source_id = source_id
self.location = self.config_entry.data[CONF_URL]
self._device_lock = asyncio.Lock()
@ -336,16 +287,13 @@ class DmsDeviceSource:
async def device_connect(self) -> None:
"""Connect to the device now that it's available."""
LOGGER.debug("Connecting to device at %s", self.location)
assert self.location
async with self._device_lock:
if self._device:
LOGGER.debug("Trying to connect when device already connected")
return
if not self.location:
LOGGER.debug("Not connecting because location is not known")
return
domain_data = get_domain_data(self.hass)
# Connect to the base UPNP device
@ -354,7 +302,7 @@ class DmsDeviceSource:
)
# Create profile wrapper
self._device = DmsDevice(upnp_device, domain_data.event_handler)
self._device = DmsDevice(upnp_device, event_handler=None)
# Update state variables. We don't care if they change, so this is
# only done once, here.
@ -396,13 +344,15 @@ class DmsDeviceSource:
"""Return a name for the media server."""
return self.config_entry.title
@property
def source_id(self) -> str:
"""Return a unique ID (slug) for this source for people to use in URLs."""
return self.config_entry.data[CONF_SOURCE_ID]
@property
def icon(self) -> str | None:
"""Return an URL to an icon for the media server."""
if not self._device:
return None
return self._device.icon
return self._device.icon if self._device else None
# MediaSource methods
@ -411,6 +361,8 @@ class DmsDeviceSource:
LOGGER.debug("async_resolve_media(%s)", identifier)
action, parameters = _parse_identifier(identifier)
assert action is not None, f"Invalid identifier: {identifier}"
if action is Action.OBJECT:
return await self.async_resolve_object(parameters)
@ -418,11 +370,8 @@ class DmsDeviceSource:
object_id = await self.async_resolve_path(parameters)
return await self.async_resolve_object(object_id)
if action is Action.SEARCH:
return await self.async_resolve_search(parameters)
LOGGER.debug("Invalid identifier %s", identifier)
raise Unresolvable(f"Invalid identifier {identifier}")
assert action is Action.SEARCH
return await self.async_resolve_search(parameters)
async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource:
"""Browse media."""
@ -577,9 +526,6 @@ class DmsDeviceSource:
children=children,
)
if media_source.children:
media_source.calculate_children_class()
return media_source
def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia:
@ -648,9 +594,6 @@ class DmsDeviceSource:
thumbnail=self._didl_thumbnail_url(item),
)
if media_source.children:
media_source.calculate_children_class()
return media_source
def _didl_thumbnail_url(self, item: didl_lite.DidlObject) -> str | None:

View File

@ -0,0 +1,27 @@
"""Small utility functions for the dlna_dms integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.util import slugify
from .const import CONF_SOURCE_ID, DOMAIN
def generate_source_id(hass: HomeAssistant, name: str) -> str:
"""Generate a unique source ID."""
other_entries = hass.config_entries.async_entries(DOMAIN)
other_source_ids: set[str] = {
other_source_id
for entry in other_entries
if (other_source_id := entry.data.get(CONF_SOURCE_ID))
}
source_id_base = slugify(name)
if source_id_base not in other_source_ids:
return source_id_base
tries = 1
while (suggested_source_id := f"{source_id_base}_{tries}") in other_source_ids:
tries += 1
return suggested_source_id

View File

@ -1,18 +1,23 @@
"""Fixtures for DLNA DMS tests."""
from __future__ import annotations
from collections.abc import AsyncGenerator, Iterable
from typing import Final
from collections.abc import AsyncIterable, Iterable
from typing import Final, cast
from unittest.mock import Mock, create_autospec, patch, seal
from async_upnp_client.client import UpnpDevice, UpnpService
from async_upnp_client.utils import absolute_url
import pytest
from homeassistant.components.dlna_dms.const import DOMAIN
from homeassistant.components.dlna_dms.dms import DlnaDmsData, get_domain_data
from homeassistant.components.dlna_dms.const import (
CONF_SOURCE_ID,
CONFIG_VERSION,
DOMAIN,
)
from homeassistant.components.dlna_dms.dms import DlnaDmsData
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@ -31,6 +36,12 @@ EVENT_CALLBACK_URL: Final = "http://192.88.99.1/notify"
NEW_DEVICE_LOCATION: Final = "http://192.88.99.7" + "/dmr_description.xml"
@pytest.fixture
async def setup_media_source(hass) -> None:
"""Set up media source."""
assert await async_setup_component(hass, "media_source", {})
@pytest.fixture
def upnp_factory_mock() -> Iterable[Mock]:
"""Mock the UpnpFactory class to construct DMS-style UPnP devices."""
@ -69,21 +80,13 @@ def upnp_factory_mock() -> Iterable[Mock]:
yield upnp_factory_instance
@pytest.fixture
async def domain_data_mock(
hass: HomeAssistant, aioclient_mock, upnp_factory_mock
) -> AsyncGenerator[DlnaDmsData, None]:
"""Mock some global data used by this component.
This includes network clients and library object factories. Mocking it
prevents network use.
Yields the actual domain data, for ease of access
"""
@pytest.fixture(autouse=True, scope="module")
def aiohttp_session_requester_mock() -> Iterable[Mock]:
"""Mock the AiohttpSessionRequester to prevent network use."""
with patch(
"homeassistant.components.dlna_dms.dms.AiohttpSessionRequester", autospec=True
):
yield get_domain_data(hass)
) as requester_mock:
yield requester_mock
@pytest.fixture
@ -92,9 +95,11 @@ def config_entry_mock() -> MockConfigEntry:
mock_entry = MockConfigEntry(
unique_id=MOCK_DEVICE_USN,
domain=DOMAIN,
version=CONFIG_VERSION,
data={
CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
},
title=MOCK_DEVICE_NAME,
)
@ -129,3 +134,40 @@ def ssdp_scanner_mock() -> Iterable[Mock]:
reg_callback = mock_scanner.return_value.async_register_callback
reg_callback.return_value = Mock(return_value=None)
yield mock_scanner.return_value
@pytest.fixture
async def device_source_mock(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock,
dms_device_mock: Mock,
) -> AsyncIterable[None]:
"""Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion."""
config_entry_mock.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry_mock.entry_id)
await hass.async_block_till_done()
# Check the DmsDeviceSource has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 0
assert ssdp_scanner_mock.async_register_callback.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
# Run the test
yield None
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Check DmsDeviceSource has cleaned up its resources
assert not config_entry_mock.update_listeners
assert (
ssdp_scanner_mock.async_register_callback.await_count
== ssdp_scanner_mock.async_register_callback.return_value.call_count
)
domain_data = cast(DlnaDmsData, hass.data[DOMAIN])
assert MOCK_DEVICE_USN not in domain_data.devices
assert MOCK_SOURCE_ID not in domain_data.sources

View File

@ -1,16 +1,17 @@
"""Test the DLNA DMS config flow."""
from __future__ import annotations
from collections.abc import Iterable
import dataclasses
from typing import Final
from unittest.mock import Mock
from unittest.mock import Mock, patch
from async_upnp_client.exceptions import UpnpError
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import ssdp
from homeassistant.components.dlna_dms.const import DOMAIN
from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL
from homeassistant.core import HomeAssistant
@ -21,17 +22,12 @@ from .conftest import (
MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
NEW_DEVICE_LOCATION,
)
from tests.common import MockConfigEntry
# Auto-use the domain_data_mock and dms_device_mock fixtures for every test in this module
pytestmark = [
pytest.mark.usefixtures("domain_data_mock"),
pytest.mark.usefixtures("dms_device_mock"),
]
WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE"
@ -68,6 +64,16 @@ MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo(
)
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Iterable[Mock]:
"""Avoid setting up the entire integration."""
with patch(
"homeassistant.components.dlna_dms.async_setup_entry",
return_value=True,
) as mock:
yield mock
async def test_user_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None:
"""Test user-init'd flow, user selects discovered device."""
ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [
@ -87,17 +93,17 @@ async def test_user_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: MOCK_DEVICE_HOST}
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == MOCK_DEVICE_NAME
assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
}
assert result["options"] == {}
await hass.async_block_till_done()
async def test_user_flow_no_devices(
hass: HomeAssistant, ssdp_scanner_mock: Mock
@ -137,12 +143,13 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None:
assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
}
assert result["options"] == {}
async def test_ssdp_flow_unavailable(
hass: HomeAssistant, domain_data_mock: Mock
hass: HomeAssistant, upnp_factory_mock: Mock
) -> None:
"""Test that SSDP discovery with an unavailable device still succeeds.
@ -157,7 +164,7 @@ async def test_ssdp_flow_unavailable(
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "confirm"
domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError
upnp_factory_mock.async_create_device.side_effect = UpnpError
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
@ -169,6 +176,7 @@ async def test_ssdp_flow_unavailable(
assert result["data"] == {
CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN,
CONF_SOURCE_ID: MOCK_SOURCE_ID,
}
assert result["options"] == {}
@ -213,9 +221,7 @@ async def test_ssdp_flow_duplicate_location(
assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION
async def test_ssdp_flow_bad_data(
hass: HomeAssistant, config_entry_mock: MockConfigEntry
) -> None:
async def test_ssdp_flow_bad_data(hass: HomeAssistant) -> None:
"""Test bad SSDP discovery information is rejected cleanly."""
# Missing location
discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_location="")
@ -241,15 +247,16 @@ async def test_ssdp_flow_bad_data(
async def test_duplicate_name(
hass: HomeAssistant, config_entry_mock: MockConfigEntry
) -> None:
"""Test device with name same as another results in no error."""
"""Test device with name same as other devices results in no error."""
# Add two entries to test generate_source_id() tries for no collisions
config_entry_mock.add_to_hass(hass)
mock_entry_1 = MockConfigEntry(
unique_id="mock_entry_1",
domain=DOMAIN,
data={
CONF_URL: "not-important",
CONF_DEVICE_ID: "not-important",
CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_1",
},
title=MOCK_DEVICE_NAME,
)
@ -286,6 +293,7 @@ async def test_duplicate_name(
assert result["data"] == {
CONF_URL: new_device_location,
CONF_DEVICE_ID: new_device_usn,
CONF_SOURCE_ID: f"{MOCK_SOURCE_ID}_2",
}
assert result["options"] == {}

View File

@ -4,81 +4,56 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable
import logging
from unittest.mock import ANY, DEFAULT, Mock, patch
from typing import Final
from unittest.mock import ANY, DEFAULT, Mock
from async_upnp_client.exceptions import UpnpConnectionError, UpnpError
from didl_lite import didl_lite
import pytest
from homeassistant.components import ssdp
from homeassistant.components import media_source, ssdp
from homeassistant.components.dlna_dms.const import DOMAIN
from homeassistant.components.dlna_dms.dms import DmsDeviceSource, get_domain_data
from homeassistant.components.dlna_dms.dms import get_domain_data
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .conftest import (
MOCK_DEVICE_LOCATION,
MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
NEW_DEVICE_LOCATION,
)
from tests.common import MockConfigEntry
# Auto-use the domain_data_mock for every test in this module
DUMMY_OBJECT_ID: Final = "123"
# Auto-use a few fixtures from conftest
pytestmark = [
pytest.mark.usefixtures("domain_data_mock"),
# Block network access
pytest.mark.usefixtures("aiohttp_session_requester_mock"),
pytest.mark.usefixtures("dms_device_mock"),
# Setup the media_source platform
pytest.mark.usefixtures("setup_media_source"),
]
async def setup_mock_component(
hass: HomeAssistant, mock_entry: MockConfigEntry
) -> DmsDeviceSource:
"""Set up a mock DlnaDmrEntity with the given configuration."""
mock_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
domain_data = get_domain_data(hass)
return next(iter(domain_data.devices.values()))
@pytest.fixture
async def connected_source_mock(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock,
dms_device_mock: Mock,
) -> AsyncIterable[DmsDeviceSource]:
"""Fixture to set up a mock DmsDeviceSource in a connected state.
Yields the entity. Cleans up the entity after the test is complete.
"""
entity = await setup_mock_component(hass, config_entry_mock)
# Check the entity has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 1
assert ssdp_scanner_mock.async_register_callback.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
# Run the test
yield entity
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Check entity has cleaned up its resources
assert not config_entry_mock.update_listeners
assert (
ssdp_scanner_mock.async_register_callback.await_count
== ssdp_scanner_mock.async_register_callback.return_value.call_count
dms_device_mock: Mock, device_source_mock: None
) -> None:
"""Fixture to set up a mock DmsDeviceSource in a connected state."""
# Make async_browse_metadata work for assert_source_available
didl_item = didl_lite.Item(
id=DUMMY_OBJECT_ID,
restricted=False,
title="Object",
res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
@pytest.fixture
@ -88,30 +63,39 @@ async def disconnected_source_mock(
config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock,
dms_device_mock: Mock,
) -> AsyncIterable[DmsDeviceSource]:
"""Fixture to set up a mock DmsDeviceSource in a disconnected state.
Yields the entity. Cleans up the entity after the test is complete.
"""
) -> AsyncIterable[None]:
"""Fixture to set up a mock DmsDeviceSource in a disconnected state."""
# Cause the connection attempt to fail
upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError
entity = await setup_mock_component(hass, config_entry_mock)
config_entry_mock.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry_mock.entry_id)
await hass.async_block_till_done()
# Check the entity has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 1
# Check the DmsDeviceSource has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 0
assert ssdp_scanner_mock.async_register_callback.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
# Make async_browse_metadata work for assert_source_available when this
# source is connected
didl_item = didl_lite.Item(
id=DUMMY_OBJECT_ID,
restricted=False,
title="Object",
res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
# Run the test
yield entity
yield
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Check entity has cleaned up its resources
# Check device source has cleaned up its resources
assert not config_entry_mock.update_listeners
assert (
ssdp_scanner_mock.async_register_callback.await_count
@ -119,24 +103,28 @@ async def disconnected_source_mock(
)
async def assert_source_available(hass: HomeAssistant) -> None:
"""Assert that the DmsDeviceSource under test can be used."""
assert await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{DUMMY_OBJECT_ID}"
)
async def assert_source_unavailable(hass: HomeAssistant) -> None:
"""Assert that the DmsDeviceSource under test cannot be used."""
with pytest.raises(Unresolvable, match="DMS is not connected"):
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{DUMMY_OBJECT_ID}"
)
async def test_unavailable_device(
hass: HomeAssistant,
upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock,
config_entry_mock: MockConfigEntry,
disconnected_source_mock: None,
) -> None:
"""Test a DlnaDmsEntity with out a connected DmsDevice."""
# Cause connection attempts to fail
upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError
with patch(
"homeassistant.components.dlna_dms.dms.DmsDevice", autospec=True
) as dms_device_constructor_mock:
connected_source_mock = await setup_mock_component(hass, config_entry_mock)
# Check device is not created
dms_device_constructor_mock.assert_not_called()
# Check attempt was made to create a device from the supplied URL
upnp_factory_mock.async_create_device.assert_awaited_once_with(MOCK_DEVICE_LOCATION)
# Check SSDP notifications are registered
@ -147,46 +135,42 @@ async def test_unavailable_device(
ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"}
)
# Quick check of the state to verify the entity has no connected DmsDevice
assert not connected_source_mock.available
# Check the name matches that supplied
assert connected_source_mock.name == MOCK_DEVICE_NAME
await assert_source_unavailable(hass)
# Check attempts to browse and resolve media give errors
with pytest.raises(BrowseError):
await connected_source_mock.async_browse_media("/browse_path")
with pytest.raises(BrowseError):
await connected_source_mock.async_browse_media(":browse_object")
with pytest.raises(BrowseError):
await connected_source_mock.async_browse_media("?browse_search")
with pytest.raises(BrowseError, match="DMS is not connected"):
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//browse_path"
)
with pytest.raises(BrowseError, match="DMS is not connected"):
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:browse_object"
)
with pytest.raises(BrowseError, match="DMS is not connected"):
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?browse_search"
)
with pytest.raises(Unresolvable, match="DMS is not connected"):
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path"
)
with pytest.raises(Unresolvable, match="DMS is not connected"):
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object"
)
with pytest.raises(Unresolvable):
await connected_source_mock.async_resolve_media("/resolve_path")
with pytest.raises(Unresolvable):
await connected_source_mock.async_resolve_media(":resolve_object")
with pytest.raises(Unresolvable):
await connected_source_mock.async_resolve_media("?resolve_search")
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Confirm SSDP notifications unregistered
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search"
)
async def test_become_available(
hass: HomeAssistant,
upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock,
config_entry_mock: MockConfigEntry,
dms_device_mock: Mock,
disconnected_source_mock: None,
) -> None:
"""Test a device becoming available after the entity is constructed."""
# Cause connection attempts to fail before adding the entity
upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError
connected_source_mock = await setup_mock_component(hass, config_entry_mock)
assert not connected_source_mock.available
# Mock device is now available.
upnp_factory_mock.async_create_device.side_effect = None
upnp_factory_mock.async_create_device.reset_mock()
@ -207,22 +191,14 @@ async def test_become_available(
# Check device was created from the supplied URL
upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION)
# Quick check of the state to verify the entity has a connected DmsDevice
assert connected_source_mock.available
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Confirm SSDP notifications unregistered
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2
await assert_source_available(hass)
async def test_alive_but_gone(
hass: HomeAssistant,
upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource,
disconnected_source_mock: None,
) -> None:
"""Test a device sending an SSDP alive announcement, but not being connectable."""
upnp_factory_mock.async_create_device.side_effect = UpnpError
@ -245,7 +221,7 @@ async def test_alive_but_gone(
upnp_factory_mock.async_create_device.assert_awaited()
# Device should still be unavailable
assert not disconnected_source_mock.available
await assert_source_unavailable(hass)
# Send the same SSDP notification, expecting no extra connection attempts
upnp_factory_mock.async_create_device.reset_mock()
@ -262,7 +238,7 @@ async def test_alive_but_gone(
await hass.async_block_till_done()
upnp_factory_mock.async_create_device.assert_not_called()
upnp_factory_mock.async_create_device.assert_not_awaited()
assert not disconnected_source_mock.available
await assert_source_unavailable(hass)
# Send an SSDP notification with a new BOOTID, indicating the device has rebooted
upnp_factory_mock.async_create_device.reset_mock()
@ -280,7 +256,7 @@ async def test_alive_but_gone(
# Rebooted device (seen via BOOTID) should mean a new connection attempt
upnp_factory_mock.async_create_device.assert_awaited()
assert not disconnected_source_mock.available
await assert_source_unavailable(hass)
# Send byebye message to indicate device is going away. Next alive message
# should result in a reconnect attempt even with same BOOTID.
@ -307,14 +283,14 @@ async def test_alive_but_gone(
# Rebooted device (seen via byebye/alive) should mean a new connection attempt
upnp_factory_mock.async_create_device.assert_awaited()
assert not disconnected_source_mock.available
await assert_source_unavailable(hass)
async def test_multiple_ssdp_alive(
hass: HomeAssistant,
upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource,
disconnected_source_mock: None,
) -> None:
"""Test multiple SSDP alive notifications is ok, only connects to device once."""
upnp_factory_mock.async_create_device.reset_mock()
@ -356,13 +332,13 @@ async def test_multiple_ssdp_alive(
upnp_factory_mock.async_create_device.assert_awaited_once_with(NEW_DEVICE_LOCATION)
# Device should be available
assert disconnected_source_mock.available
await assert_source_available(hass)
async def test_ssdp_byebye(
hass: HomeAssistant,
ssdp_scanner_mock: Mock,
connected_source_mock: DmsDeviceSource,
connected_source_mock: None,
) -> None:
"""Test device is disconnected when byebye is received."""
# First byebye will cause a disconnect
@ -379,7 +355,7 @@ async def test_ssdp_byebye(
)
# Device should be gone
assert not connected_source_mock.available
await assert_source_unavailable(hass)
# Second byebye will do nothing
await ssdp_callback(
@ -398,12 +374,11 @@ async def test_ssdp_update_seen_bootid(
hass: HomeAssistant,
ssdp_scanner_mock: Mock,
upnp_factory_mock: Mock,
disconnected_source_mock: DmsDeviceSource,
disconnected_source_mock: None,
) -> None:
"""Test device does not reconnect when it gets ssdp:update with next bootid."""
# Start with a disconnected device
entity = disconnected_source_mock
assert not entity.available
await assert_source_unavailable(hass)
# "Reconnect" the device
upnp_factory_mock.async_create_device.reset_mock()
@ -424,7 +399,7 @@ async def test_ssdp_update_seen_bootid(
await hass.async_block_till_done()
# Device should be connected
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with next boot ID
@ -445,7 +420,7 @@ async def test_ssdp_update_seen_bootid(
await hass.async_block_till_done()
# Device was not reconnected, even with a new boot ID
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with same next boot ID, again
@ -466,7 +441,7 @@ async def test_ssdp_update_seen_bootid(
await hass.async_block_till_done()
# Nothing should change
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with bad next boot ID
@ -487,7 +462,7 @@ async def test_ssdp_update_seen_bootid(
await hass.async_block_till_done()
# Nothing should change
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send a new SSDP alive with the new boot ID, device should not reconnect
@ -503,7 +478,7 @@ async def test_ssdp_update_seen_bootid(
)
await hass.async_block_till_done()
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
@ -511,12 +486,11 @@ async def test_ssdp_update_missed_bootid(
hass: HomeAssistant,
ssdp_scanner_mock: Mock,
upnp_factory_mock: Mock,
disconnected_source_mock: DmsDeviceSource,
disconnected_source_mock: None,
) -> None:
"""Test device disconnects when it gets ssdp:update bootid it wasn't expecting."""
# Start with a disconnected device
entity = disconnected_source_mock
assert not entity.available
await assert_source_unavailable(hass)
# "Reconnect" the device
upnp_factory_mock.async_create_device.reset_mock()
@ -537,7 +511,7 @@ async def test_ssdp_update_missed_bootid(
await hass.async_block_till_done()
# Device should be connected
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP update with skipped boot ID (not previously seen)
@ -558,7 +532,7 @@ async def test_ssdp_update_missed_bootid(
await hass.async_block_till_done()
# Device should not *re*-connect yet
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send a new SSDP alive with the new boot ID, device should reconnect
@ -574,7 +548,7 @@ async def test_ssdp_update_missed_bootid(
)
await hass.async_block_till_done()
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 2
@ -582,12 +556,11 @@ async def test_ssdp_bootid(
hass: HomeAssistant,
upnp_factory_mock: Mock,
ssdp_scanner_mock: Mock,
disconnected_source_mock: DmsDeviceSource,
disconnected_source_mock: None,
) -> None:
"""Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect."""
# Start with a disconnected device
entity = disconnected_source_mock
assert not entity.available
await assert_source_unavailable(hass)
# "Reconnect" the device
upnp_factory_mock.async_create_device.side_effect = None
@ -607,7 +580,7 @@ async def test_ssdp_bootid(
)
await hass.async_block_till_done()
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send SSDP alive with same boot ID, nothing should happen
@ -623,7 +596,7 @@ async def test_ssdp_bootid(
)
await hass.async_block_till_done()
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 1
# Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected
@ -639,44 +612,32 @@ async def test_ssdp_bootid(
)
await hass.async_block_till_done()
assert entity.available
await assert_source_available(hass)
assert upnp_factory_mock.async_create_device.await_count == 2
async def test_repeated_connect(
caplog: pytest.LogCaptureFixture,
connected_source_mock: DmsDeviceSource,
hass: HomeAssistant,
upnp_factory_mock: Mock,
connected_source_mock: None,
) -> None:
"""Test trying to connect an already connected device is safely ignored."""
upnp_factory_mock.async_create_device.reset_mock()
# Calling internal function directly to skip trying to time 2 SSDP messages carefully
with caplog.at_level(logging.DEBUG):
await connected_source_mock.device_connect()
assert (
"Trying to connect when device already connected" == caplog.records[-1].message
)
assert not upnp_factory_mock.async_create_device.await_count
async def test_connect_no_location(
caplog: pytest.LogCaptureFixture,
disconnected_source_mock: DmsDeviceSource,
upnp_factory_mock: Mock,
) -> None:
"""Test trying to connect without a location is safely ignored."""
disconnected_source_mock.location = ""
upnp_factory_mock.async_create_device.reset_mock()
# Calling internal function directly to skip trying to time 2 SSDP messages carefully
domain_data = get_domain_data(hass)
device_source = domain_data.sources[MOCK_SOURCE_ID]
with caplog.at_level(logging.DEBUG):
await disconnected_source_mock.device_connect()
assert "Not connecting because location is not known" == caplog.records[-1].message
await device_source.device_connect()
assert not upnp_factory_mock.async_create_device.await_count
await assert_source_available(hass)
async def test_become_unavailable(
hass: HomeAssistant,
connected_source_mock: DmsDeviceSource,
connected_source_mock: None,
dms_device_mock: Mock,
) -> None:
"""Test a device becoming unavailable."""
@ -689,17 +650,18 @@ async def test_become_unavailable(
)
# Check async_resolve_object currently works
await connected_source_mock.async_resolve_media(":object_id")
assert await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id"
)
# Now break the network connection
dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError
# The device should be considered available until next contacted
assert connected_source_mock.available
# async_resolve_object should fail
with pytest.raises(Unresolvable):
await connected_source_mock.async_resolve_media(":object_id")
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id"
)
# The device should now be unavailable
assert not connected_source_mock.available
await assert_source_unavailable(hass)

View File

@ -1,5 +1,6 @@
"""Test the interface methods of DmsDeviceSource, except availability."""
from collections.abc import AsyncIterable
"""Test the browse and resolve methods of DmsDeviceSource."""
from __future__ import annotations
from typing import Final, Union
from unittest.mock import ANY, Mock, call
@ -8,215 +9,155 @@ from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
from didl_lite import didl_lite
import pytest
from homeassistant.components import media_source, ssdp
from homeassistant.components.dlna_dms.const import DLNA_SORT_CRITERIA, DOMAIN
from homeassistant.components.dlna_dms.dms import (
ActionError,
DeviceConnectionError,
DlnaDmsData,
DmsDeviceSource,
)
from homeassistant.components.dlna_dms.dms import DidlPlayMedia
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.media_source.error import Unresolvable
from homeassistant.components.media_source.models import BrowseMediaSource
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant
from .conftest import (
MOCK_DEVICE_BASE_URL,
MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE,
MOCK_DEVICE_UDN,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
)
from tests.common import MockConfigEntry
# Auto-use a few fixtures from conftest
pytestmark = [
# Block network access
pytest.mark.usefixtures("aiohttp_session_requester_mock"),
# Setup the media_source platform
pytest.mark.usefixtures("setup_media_source"),
# Have a connected device so that test can successfully call browse and resolve
pytest.mark.usefixtures("device_source_mock"),
]
BrowseResultList = list[Union[didl_lite.DidlObject, didl_lite.Descriptor]]
@pytest.fixture
async def device_source_mock(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock,
dms_device_mock: Mock,
domain_data_mock: DlnaDmsData,
) -> AsyncIterable[DmsDeviceSource]:
"""Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion."""
await hass.config_entries.async_add(config_entry_mock)
await hass.async_block_till_done()
mock_entity = domain_data_mock.devices[MOCK_DEVICE_USN]
# Check the DmsDeviceSource has registered all needed listeners
assert len(config_entry_mock.update_listeners) == 1
assert ssdp_scanner_mock.async_register_callback.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
# Run the test
yield mock_entity
# Unload config entry to clean up
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
"require_restart": False
}
# Check DmsDeviceSource has cleaned up its resources
assert not config_entry_mock.update_listeners
assert (
ssdp_scanner_mock.async_register_callback.await_count
== ssdp_scanner_mock.async_register_callback.return_value.call_count
async def async_resolve_media(
hass: HomeAssistant, media_content_id: str
) -> DidlPlayMedia:
"""Call media_source.async_resolve_media with the test source's ID."""
result = await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}"
)
assert MOCK_DEVICE_USN not in domain_data_mock.devices
assert MOCK_SOURCE_ID not in domain_data_mock.sources
assert isinstance(result, DidlPlayMedia)
return result
async def test_update_source_id(
async def async_browse_media(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
device_source_mock: DmsDeviceSource,
domain_data_mock: DlnaDmsData,
) -> None:
"""Test the config listener updates the source_id and source list upon title change."""
new_title: Final = "New Name"
new_source_id: Final = "new_name"
assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID}
hass.config_entries.async_update_entry(config_entry_mock, title=new_title)
await hass.async_block_till_done()
assert device_source_mock.source_id == new_source_id
assert domain_data_mock.sources.keys() == {new_source_id}
async def test_update_existing_source_id(
caplog: pytest.LogCaptureFixture,
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
device_source_mock: DmsDeviceSource,
domain_data_mock: DlnaDmsData,
) -> None:
"""Test the config listener gracefully handles colliding source_id."""
new_title: Final = "New Name"
new_source_id: Final = "new_name"
new_source_id_2: Final = "new_name_1"
# Set up another config entry to collide with the new source_id
colliding_entry = MockConfigEntry(
unique_id=f"different-udn::{MOCK_DEVICE_TYPE}",
domain=DOMAIN,
data={
CONF_URL: "http://192.88.99.22/dms_description.xml",
CONF_DEVICE_ID: f"different-udn::{MOCK_DEVICE_TYPE}",
},
title=new_title,
media_content_id: str | None,
) -> BrowseMediaSource:
"""Call media_source.async_browse_media with the test source's ID."""
return await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}"
)
await hass.config_entries.async_add(colliding_entry)
await hass.async_block_till_done()
assert device_source_mock.source_id == MOCK_SOURCE_ID
assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID, new_source_id}
assert domain_data_mock.sources[MOCK_SOURCE_ID] is device_source_mock
# Update the existing entry to match the other entry's name
hass.config_entries.async_update_entry(config_entry_mock, title=new_title)
await hass.async_block_till_done()
# The existing device's source ID should be a newly generated slug
assert device_source_mock.source_id == new_source_id_2
assert domain_data_mock.sources.keys() == {new_source_id, new_source_id_2}
assert domain_data_mock.sources[new_source_id_2] is device_source_mock
# Changing back to the old name should not cause issues
hass.config_entries.async_update_entry(config_entry_mock, title=MOCK_DEVICE_NAME)
await hass.async_block_till_done()
assert device_source_mock.source_id == MOCK_SOURCE_ID
assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID, new_source_id}
assert domain_data_mock.sources[MOCK_SOURCE_ID] is device_source_mock
# Remove the collision and try again
await hass.config_entries.async_remove(colliding_entry.entry_id)
assert domain_data_mock.sources.keys() == {MOCK_SOURCE_ID}
hass.config_entries.async_update_entry(config_entry_mock, title=new_title)
await hass.async_block_till_done()
assert device_source_mock.source_id == new_source_id
assert domain_data_mock.sources.keys() == {new_source_id}
async def test_catch_request_error_unavailable(
device_source_mock: DmsDeviceSource,
hass: HomeAssistant, ssdp_scanner_mock: Mock
) -> None:
"""Test the device is checked for availability before trying requests."""
device_source_mock._device = None
# DmsDevice notifies of disconnect via SSDP
ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0]
await ssdp_callback(
ssdp.SsdpServiceInfo(
ssdp_usn=MOCK_DEVICE_USN,
ssdp_udn=MOCK_DEVICE_UDN,
ssdp_headers={"NTS": "ssdp:byebye"},
ssdp_st=MOCK_DEVICE_TYPE,
upnp={},
),
ssdp.SsdpChange.BYEBYE,
)
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_resolve_object("id")
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_resolve_path("path")
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_resolve_search("query")
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_browse_object("object_id")
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_browse_search("query")
# All attempts to use the device should give an error
with pytest.raises(Unresolvable, match="DMS is not connected"):
# Resolve object
await async_resolve_media(hass, ":id")
with pytest.raises(Unresolvable, match="DMS is not connected"):
# Resolve path
await async_resolve_media(hass, "/path")
with pytest.raises(Unresolvable, match="DMS is not connected"):
# Resolve search
await async_resolve_media(hass, "?query")
with pytest.raises(BrowseError, match="DMS is not connected"):
# Browse object
await async_browse_media(hass, ":id")
with pytest.raises(BrowseError, match="DMS is not connected"):
# Browse path
await async_browse_media(hass, "/path")
with pytest.raises(BrowseError, match="DMS is not connected"):
# Browse search
await async_browse_media(hass, "?query")
async def test_catch_request_error(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_catch_request_error(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test errors when making requests to the device are handled."""
dms_device_mock.async_browse_metadata.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.NO_SUCH_OBJECT
)
with pytest.raises(ActionError, match="No such object: bad_id"):
await device_source_mock.async_resolve_media(":bad_id")
with pytest.raises(Unresolvable, match="No such object: bad_id"):
await async_resolve_media(hass, ":bad_id")
dms_device_mock.async_search_directory.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA
)
with pytest.raises(ActionError, match="Invalid query: bad query"):
await device_source_mock.async_resolve_media("?bad query")
with pytest.raises(Unresolvable, match="Invalid query: bad query"):
await async_resolve_media(hass, "?bad query")
dms_device_mock.async_browse_metadata.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.CANNOT_PROCESS_REQUEST
)
with pytest.raises(DeviceConnectionError, match="Server failure: "):
await device_source_mock.async_resolve_media(":good_id")
with pytest.raises(BrowseError, match="Server failure: "):
await async_resolve_media(hass, ":good_id")
dms_device_mock.async_browse_metadata.side_effect = UpnpError
with pytest.raises(
DeviceConnectionError, match="Server communication failure: UpnpError(.*)"
BrowseError, match="Server communication failure: UpnpError(.*)"
):
await device_source_mock.async_resolve_media(":bad_id")
await async_resolve_media(hass, ":bad_id")
# UpnpConnectionErrors will cause the device_source_mock to disconnect from the device
assert device_source_mock.available
async def test_catch_upnp_connection_error(
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test UpnpConnectionError causes the device source to disconnect from the device."""
# First check the source can be used
object_id = "foo"
didl_item = didl_lite.Item(
id=object_id,
restricted="false",
title="Object",
res=[didl_lite.Resource(uri="foo", protocol_info="http-get:*:audio/mpeg")],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
await async_browse_media(hass, f":{object_id}")
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
object_id, metadata_filter=ANY
)
# Cause a UpnpConnectionError when next browsing
dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError
with pytest.raises(
DeviceConnectionError, match="Server disconnected: UpnpConnectionError(.*)"
BrowseError, match="Server disconnected: UpnpConnectionError(.*)"
):
await device_source_mock.async_resolve_media(":bad_id")
assert not device_source_mock.available
await async_browse_media(hass, f":{object_id}")
# Clear the error, but the device should be disconnected
dms_device_mock.async_browse_metadata.side_effect = None
with pytest.raises(BrowseError, match="DMS is not connected"):
await async_browse_media(hass, f":{object_id}")
async def test_icon(device_source_mock: DmsDeviceSource, dms_device_mock: Mock) -> None:
"""Test the device's icon URL is returned."""
assert device_source_mock.icon == dms_device_mock.icon
device_source_mock._device = None
assert device_source_mock.icon is None
async def test_resolve_media_invalid(device_source_mock: DmsDeviceSource) -> None:
"""Test async_resolve_media will raise Unresolvable when an identifier isn't supplied."""
with pytest.raises(Unresolvable, match="Invalid identifier.*"):
await device_source_mock.async_resolve_media("")
async def test_resolve_media_object(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_resolve_media_object(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test the async_resolve_object method via async_resolve_media."""
object_id: Final = "123"
res_url: Final = "foo/bar"
@ -230,7 +171,7 @@ async def test_resolve_media_object(
res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
result = await device_source_mock.async_resolve_media(f":{object_id}")
result = await async_resolve_media(hass, f":{object_id}")
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
object_id, metadata_filter="*"
)
@ -251,7 +192,7 @@ async def test_resolve_media_object(
],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
result = await device_source_mock.async_resolve_media(f":{object_id}")
result = await async_resolve_media(hass, f":{object_id}")
assert result.url == res_abs_url
assert result.mime_type == res_mime
assert result.didl_metadata is didl_item
@ -268,7 +209,7 @@ async def test_resolve_media_object(
],
)
dms_device_mock.async_browse_metadata.return_value = didl_item
result = await device_source_mock.async_resolve_media(f":{object_id}")
result = await async_resolve_media(hass, f":{object_id}")
assert result.url == res_abs_url
assert result.mime_type == res_mime
assert result.didl_metadata is didl_item
@ -282,7 +223,7 @@ async def test_resolve_media_object(
)
dms_device_mock.async_browse_metadata.return_value = didl_item
with pytest.raises(Unresolvable, match="Object has no resources"):
await device_source_mock.async_resolve_media(f":{object_id}")
await async_resolve_media(hass, f":{object_id}")
# Failure case: resources are not playable
didl_item = didl_lite.Item(
@ -293,13 +234,13 @@ async def test_resolve_media_object(
)
dms_device_mock.async_browse_metadata.return_value = didl_item
with pytest.raises(Unresolvable, match="Object has no playable resources"):
await device_source_mock.async_resolve_media(f":{object_id}")
await async_resolve_media(hass, f":{object_id}")
async def test_resolve_media_path(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test the async_resolve_path method via async_resolve_media."""
# Path resolution involves searching each component of the path, then
# browsing the metadata of the final object found.
path: Final = "path/to/thing"
object_ids: Final = ["path_id", "to_id", "thing_id"]
res_url: Final = "foo/bar"
@ -324,7 +265,7 @@ async def test_resolve_media_path(
title="thing",
res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")],
)
result = await device_source_mock.async_resolve_media(f"/{path}")
result = await async_resolve_media(hass, f"/{path}")
assert dms_device_mock.async_search_directory.await_args_list == [
call(
parent_id,
@ -340,7 +281,7 @@ async def test_resolve_media_path(
# Test a path starting with a / (first / is path action, second / is root of path)
dms_device_mock.async_search_directory.reset_mock()
dms_device_mock.async_search_directory.side_effect = search_directory_result
result = await device_source_mock.async_resolve_media(f"//{path}")
result = await async_resolve_media(hass, f"//{path}")
assert dms_device_mock.async_search_directory.await_args_list == [
call(
parent_id,
@ -354,44 +295,14 @@ async def test_resolve_media_path(
assert result.mime_type == res_mime
async def test_resolve_path_simple(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
"""Test async_resolve_path for simple success as for test_resolve_media_path."""
path: Final = "path/to/thing"
object_ids: Final = ["path_id", "to_id", "thing_id"]
search_directory_result = []
for ob_id, ob_title in zip(object_ids, path.split("/")):
didl_item = didl_lite.Item(
id=ob_id,
restricted="false",
title=ob_title,
res=[],
)
search_directory_result.append(DmsDevice.BrowseResult([didl_item], 1, 1, 0))
dms_device_mock.async_search_directory.side_effect = search_directory_result
result = await device_source_mock.async_resolve_path(path)
assert dms_device_mock.async_search_directory.call_args_list == [
call(
parent_id,
search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"',
metadata_filter=["id", "upnp:class", "dc:title"],
requested_count=1,
)
for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/"))
]
assert result == object_ids[-1]
assert not dms_device_mock.async_browse_direct_children.await_count
async def test_resolve_path_browsed(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_resolve_path: action error results in browsing."""
path: Final = "path/to/thing"
object_ids: Final = ["path_id", "to_id", "thing_id"]
res_url: Final = "foo/bar"
res_mime: Final = "audio/mpeg"
# Setup expected calls
search_directory_result = []
for ob_id, ob_title in zip(object_ids, path.split("/")):
didl_item = didl_lite.Item(
@ -417,7 +328,15 @@ async def test_resolve_path_browsed(
DmsDevice.BrowseResult(browse_children_result, 3, 3, 0)
]
result = await device_source_mock.async_resolve_path(path)
dms_device_mock.async_browse_metadata.return_value = didl_lite.Item(
id=object_ids[-1],
restricted="false",
title="thing",
res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")],
)
# Perform the action to test
result = await async_resolve_media(hass, path)
# All levels should have an attempted search
assert dms_device_mock.async_search_directory.await_args_list == [
call(
@ -428,7 +347,7 @@ async def test_resolve_path_browsed(
)
for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/"))
]
assert result == object_ids[-1]
assert result.didl_metadata.id == object_ids[-1]
# 2nd level should also be browsed
assert dms_device_mock.async_browse_direct_children.await_args_list == [
call("path_id", metadata_filter=["id", "upnp:class", "dc:title"])
@ -436,7 +355,7 @@ async def test_resolve_path_browsed(
async def test_resolve_path_browsed_nothing(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test async_resolve_path: action error results in browsing, but nothing found."""
dms_device_mock.async_search_directory.side_effect = UpnpActionError()
@ -445,7 +364,7 @@ async def test_resolve_path_browsed_nothing(
DmsDevice.BrowseResult([], 0, 0, 0)
]
with pytest.raises(Unresolvable, match="No contents for thing in thing/other"):
await device_source_mock.async_resolve_path(r"thing/other")
await async_resolve_media(hass, "thing/other")
# There are children, but they don't match
dms_device_mock.async_browse_direct_children.side_effect = [
@ -461,12 +380,10 @@ async def test_resolve_path_browsed_nothing(
)
]
with pytest.raises(Unresolvable, match="Nothing found for thing in thing/other"):
await device_source_mock.async_resolve_path(r"thing/other")
await async_resolve_media(hass, "thing/other")
async def test_resolve_path_quoted(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_resolve_path_quoted(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_resolve_path: quotes and backslashes in the path get escaped correctly."""
dms_device_mock.async_search_directory.side_effect = [
DmsDevice.BrowseResult(
@ -484,8 +401,8 @@ async def test_resolve_path_quoted(
),
UpnpError("Quick abort"),
]
with pytest.raises(DeviceConnectionError):
await device_source_mock.async_resolve_path(r'path/quote"back\slash')
with pytest.raises(Unresolvable):
await async_resolve_media(hass, r'path/quote"back\slash')
assert dms_device_mock.async_search_directory.await_args_list == [
call(
"0",
@ -503,7 +420,7 @@ async def test_resolve_path_quoted(
async def test_resolve_path_ambiguous(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test async_resolve_path: ambiguous results (too many matches) gives error."""
dms_device_mock.async_search_directory.side_effect = [
@ -530,23 +447,21 @@ async def test_resolve_path_ambiguous(
with pytest.raises(
Unresolvable, match="Too many items found for thing in thing/other"
):
await device_source_mock.async_resolve_path(r"thing/other")
await async_resolve_media(hass, "thing/other")
async def test_resolve_path_no_such_container(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test async_resolve_path: Explicit check for NO_SUCH_CONTAINER."""
dms_device_mock.async_search_directory.side_effect = UpnpActionError(
error_code=ContentDirectoryErrorCode.NO_SUCH_CONTAINER
)
with pytest.raises(Unresolvable, match="No such container: 0"):
await device_source_mock.async_resolve_path(r"thing/other")
await async_resolve_media(hass, "thing/other")
async def test_resolve_media_search(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_resolve_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test the async_resolve_search method via async_resolve_media."""
res_url: Final = "foo/bar"
res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}"
@ -557,7 +472,7 @@ async def test_resolve_media_search(
[], 0, 0, 0
)
with pytest.raises(Unresolvable, match='Nothing found for dc:title="thing"'):
await device_source_mock.async_resolve_media('?dc:title="thing"')
await async_resolve_media(hass, '?dc:title="thing"')
assert dms_device_mock.async_search_directory.await_args_list == [
call(
container_id="0",
@ -578,7 +493,7 @@ async def test_resolve_media_search(
dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
[didl_item], 1, 1, 0
)
result = await device_source_mock.async_resolve_media('?dc:title="thing"')
result = await async_resolve_media(hass, '?dc:title="thing"')
assert result.url == res_abs_url
assert result.mime_type == res_mime
assert result.didl_metadata is didl_item
@ -590,7 +505,7 @@ async def test_resolve_media_search(
dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
[didl_item], 1, 2, 0
)
result = await device_source_mock.async_resolve_media('?dc:title="thing"')
result = await async_resolve_media(hass, '?dc:title="thing"')
assert result.url == res_abs_url
assert result.mime_type == res_mime
assert result.didl_metadata is didl_item
@ -600,12 +515,10 @@ async def test_resolve_media_search(
[didl_lite.Descriptor("id", "namespace")], 1, 1, 0
)
with pytest.raises(Unresolvable, match="Descriptor.* is not a DidlObject"):
await device_source_mock.async_resolve_media('?dc:title="thing"')
await async_resolve_media(hass, '?dc:title="thing"')
async def test_browse_media_root(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_browse_media_root(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_browse_media with no identifier will browse the root of the device."""
dms_device_mock.async_browse_metadata.return_value = didl_lite.DidlObject(
id="0", restricted="false", title="root"
@ -613,8 +526,25 @@ async def test_browse_media_root(
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0
)
# No identifier (first opened in media browser)
result = await device_source_mock.async_browse_media(None)
result = await media_source.async_browse_media(hass, f"media-source://{DOMAIN}")
assert result.identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.title == MOCK_DEVICE_NAME
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
"0", metadata_filter=ANY
)
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
"0", metadata_filter=ANY, sort_criteria=ANY
)
dms_device_mock.async_browse_metadata.reset_mock()
dms_device_mock.async_browse_direct_children.reset_mock()
# Only source ID, no object ID
result = await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}"
)
assert result.identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.title == MOCK_DEVICE_NAME
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
@ -627,7 +557,9 @@ async def test_browse_media_root(
dms_device_mock.async_browse_metadata.reset_mock()
dms_device_mock.async_browse_direct_children.reset_mock()
# Empty string identifier
result = await device_source_mock.async_browse_media("")
result = await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/"
)
assert result.identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.title == MOCK_DEVICE_NAME
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
@ -638,9 +570,7 @@ async def test_browse_media_root(
)
async def test_browse_media_object(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_browse_media_object(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_browse_object via async_browse_media."""
object_id = "1234"
child_titles = ("Item 1", "Thing", "Item 2")
@ -663,7 +593,7 @@ async def test_browse_media_object(
)
dms_device_mock.async_browse_direct_children.return_value = children_result
result = await device_source_mock.async_browse_media(f":{object_id}")
result = await async_browse_media(hass, f":{object_id}")
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
object_id, metadata_filter=ANY
)
@ -687,7 +617,7 @@ async def test_browse_media_object(
async def test_browse_object_sort_anything(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test sort criteria for children where device allows anything."""
dms_device_mock.sort_capabilities = ["*"]
@ -699,7 +629,7 @@ async def test_browse_object_sort_anything(
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0
)
await device_source_mock.async_browse_object("0")
await async_browse_media(hass, ":0")
# Sort criteria should be dlna_dms's default
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
@ -708,7 +638,7 @@ async def test_browse_object_sort_anything(
async def test_browse_object_sort_superset(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test sorting where device allows superset of integration's criteria."""
dms_device_mock.sort_capabilities = [
@ -727,7 +657,7 @@ async def test_browse_object_sort_superset(
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0
)
await device_source_mock.async_browse_object("0")
await async_browse_media(hass, ":0")
# Sort criteria should be dlna_dms's default
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
@ -736,7 +666,7 @@ async def test_browse_object_sort_superset(
async def test_browse_object_sort_subset(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test sorting where device allows subset of integration's criteria."""
dms_device_mock.sort_capabilities = [
@ -751,7 +681,7 @@ async def test_browse_object_sort_subset(
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
[], 0, 0, 0
)
await device_source_mock.async_browse_object("0")
await async_browse_media(hass, ":0")
# Sort criteria should be reduced to only those allowed,
# and in the order specified by DLNA_SORT_CRITERIA
@ -761,9 +691,7 @@ async def test_browse_object_sort_subset(
)
async def test_browse_media_path(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_browse_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_browse_media with a path."""
title = "folder"
con_id = "123"
@ -776,7 +704,7 @@ async def test_browse_media_path(
[], 0, 0, 0
)
result = await device_source_mock.async_browse_media(f"{title}")
result = await async_browse_media(hass, title)
assert result.identifier == f"{MOCK_SOURCE_ID}/:{con_id}"
assert result.title == title
@ -794,9 +722,7 @@ async def test_browse_media_path(
)
async def test_browse_media_search(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_browse_media_search(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test async_browse_media with a search query."""
query = 'dc:title contains "FooBar"'
object_details = (("111", "FooBar baz"), ("432", "Not FooBar"), ("99", "FooBar"))
@ -814,7 +740,7 @@ async def test_browse_media_search(
1, didl_lite.Descriptor("id", "name_space")
)
result = await device_source_mock.async_browse_media(f"?{query}")
result = await async_browse_media(hass, f"?{query}")
assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}"
assert result.title == "Search results"
assert result.children
@ -827,7 +753,7 @@ async def test_browse_media_search(
async def test_browse_search_invalid(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test searching with an invalid query gives a BrowseError."""
query = "title == FooBar"
@ -835,11 +761,11 @@ async def test_browse_search_invalid(
error_code=ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA
)
with pytest.raises(BrowseError, match=f"Invalid query: {query}"):
await device_source_mock.async_browse_media(f"?{query}")
await async_browse_media(hass, f"?{query}")
async def test_browse_search_no_results(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock
) -> None:
"""Test a search with no results does not give an error."""
query = 'dc:title contains "FooBar"'
@ -847,15 +773,13 @@ async def test_browse_search_no_results(
[], 0, 0, 0
)
result = await device_source_mock.async_browse_media(f"?{query}")
result = await async_browse_media(hass, f"?{query}")
assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}"
assert result.title == "Search results"
assert not result.children
async def test_thumbnail(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_thumbnail(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test getting thumbnails URLs for items."""
# Use browse_search to get multiple items at once for least effort
dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult(
@ -901,16 +825,14 @@ async def test_thumbnail(
0,
)
result = await device_source_mock.async_browse_media("?query")
result = await async_browse_media(hass, "?query")
assert result.children
assert result.children[0].thumbnail == f"{MOCK_DEVICE_BASE_URL}/a_thumb.jpg"
assert result.children[1].thumbnail == f"{MOCK_DEVICE_BASE_URL}/b_thumb.png"
assert result.children[2].thumbnail is None
async def test_can_play(
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
) -> None:
async def test_can_play(hass: HomeAssistant, dms_device_mock: Mock) -> None:
"""Test determination of playability for items."""
protocol_infos = [
# No protocol info for resource
@ -945,7 +867,7 @@ async def test_can_play(
search_results, len(search_results), len(search_results), 0
)
result = await device_source_mock.async_browse_media("?query")
result = await async_browse_media(hass, "?query")
assert result.children
assert not result.children[0].can_play
for idx, info_can_play in enumerate(protocol_infos):

View File

@ -1,18 +1,32 @@
"""Test the DLNA DMS component setup, cleanup, and module-level functions."""
from typing import cast
from unittest.mock import Mock
from homeassistant.components.dlna_dms.const import DOMAIN
from homeassistant.components.dlna_dms.const import (
CONF_SOURCE_ID,
CONFIG_VERSION,
DOMAIN,
)
from homeassistant.components.dlna_dms.dms import DlnaDmsData
from homeassistant.const import CONF_DEVICE_ID, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .conftest import (
MOCK_DEVICE_LOCATION,
MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
)
from tests.common import MockConfigEntry
async def test_resource_lifecycle(
hass: HomeAssistant,
domain_data_mock: DlnaDmsData,
aiohttp_session_requester_mock: Mock,
config_entry_mock: MockConfigEntry,
ssdp_scanner_mock: Mock,
dms_device_mock: Mock,
@ -23,14 +37,15 @@ async def test_resource_lifecycle(
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
# Check the entity is created and working
assert len(domain_data_mock.devices) == 1
assert len(domain_data_mock.sources) == 1
entity = next(iter(domain_data_mock.devices.values()))
# Check the device source is created and working
domain_data = cast(DlnaDmsData, hass.data[DOMAIN])
assert len(domain_data.devices) == 1
assert len(domain_data.sources) == 1
entity = next(iter(domain_data.devices.values()))
assert entity.available is True
# Check update listeners are subscribed
assert len(config_entry_mock.update_listeners) == 1
# Check listener subscriptions
assert len(config_entry_mock.update_listeners) == 0
assert ssdp_scanner_mock.async_register_callback.await_count == 2
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
@ -54,6 +69,63 @@ async def test_resource_lifecycle(
assert dms_device_mock.async_unsubscribe_services.await_count == 0
assert dms_device_mock.on_event is None
# Check entity is gone
assert not domain_data_mock.devices
assert not domain_data_mock.sources
# Check device source is gone
assert not domain_data.devices
assert not domain_data.sources
async def test_migrate_entry(hass: HomeAssistant) -> None:
"""Test migrating a config entry from version 1 to version 2."""
# Create mock entry with version 1
mock_entry = MockConfigEntry(
unique_id=MOCK_DEVICE_USN,
domain=DOMAIN,
version=1,
data={
CONF_URL: MOCK_DEVICE_LOCATION,
CONF_DEVICE_ID: MOCK_DEVICE_USN,
},
title=MOCK_DEVICE_NAME,
)
# Set it up
mock_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
# Check that it has a source_id now
updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
assert updated_entry
assert updated_entry.version == CONFIG_VERSION
assert updated_entry.data.get(CONF_SOURCE_ID) == MOCK_SOURCE_ID
async def test_migrate_entry_collision(
hass: HomeAssistant, config_entry_mock: MockConfigEntry
) -> None:
"""Test migrating a config entry with a potentially colliding source ID."""
# Use existing mock entry
config_entry_mock.add_to_hass(hass)
# Create mock entry with same name, and old version, that needs migrating
mock_entry = MockConfigEntry(
unique_id=f"udn-migrating::{MOCK_DEVICE_TYPE}",
domain=DOMAIN,
version=1,
data={
CONF_URL: "http://192.88.99.22/dms_description.xml",
CONF_DEVICE_ID: f"different-udn::{MOCK_DEVICE_TYPE}",
},
title=MOCK_DEVICE_NAME,
)
mock_entry.add_to_hass(hass)
# Set the integration up
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
# Check that it has a source_id now
updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
assert updated_entry
assert updated_entry.version == CONFIG_VERSION
assert updated_entry.data.get(CONF_SOURCE_ID) == f"{MOCK_SOURCE_ID}_1"

View File

@ -5,8 +5,9 @@ from async_upnp_client.exceptions import UpnpError
from didl_lite import didl_lite
import pytest
from homeassistant.components import media_source
from homeassistant.components.dlna_dms.const import DOMAIN
from homeassistant.components.dlna_dms.dms import DlnaDmsData, DmsDeviceSource
from homeassistant.components.dlna_dms.dms import DidlPlayMedia
from homeassistant.components.dlna_dms.media_source import (
DmsMediaSource,
async_get_media_source,
@ -24,30 +25,18 @@ from .conftest import (
MOCK_DEVICE_BASE_URL,
MOCK_DEVICE_NAME,
MOCK_DEVICE_TYPE,
MOCK_DEVICE_USN,
MOCK_SOURCE_ID,
)
from tests.common import MockConfigEntry
@pytest.fixture
async def entity(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
dms_device_mock: Mock,
domain_data_mock: DlnaDmsData,
) -> DmsDeviceSource:
"""Fixture to set up a DmsDeviceSource in a connected state and cleanup at completion."""
await hass.config_entries.async_add(config_entry_mock)
await hass.async_block_till_done()
return domain_data_mock.devices[MOCK_DEVICE_USN]
@pytest.fixture
async def dms_source(hass: HomeAssistant, entity: DmsDeviceSource) -> DmsMediaSource:
"""Fixture providing a pre-constructed DmsMediaSource with a single device."""
return DmsMediaSource(hass)
# Auto-use a few fixtures from conftest
pytestmark = [
# Block network access
pytest.mark.usefixtures("aiohttp_session_requester_mock"),
# Setup the media_source platform
pytest.mark.usefixtures("setup_media_source"),
]
async def test_get_media_source(hass: HomeAssistant) -> None:
@ -66,41 +55,44 @@ async def test_resolve_media_unconfigured(hass: HomeAssistant) -> None:
async def test_resolve_media_bad_identifier(
hass: HomeAssistant, dms_source: DmsMediaSource
hass: HomeAssistant, device_source_mock: None
) -> None:
"""Test trying to resolve an item that has an unresolvable identifier."""
# Empty identifier
item = MediaSourceItem(hass, DOMAIN, "")
with pytest.raises(Unresolvable, match="No source ID.*"):
await dms_source.async_resolve_media(item)
await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}")
# Identifier has media_id but no source_id
item = MediaSourceItem(hass, DOMAIN, "/media_id")
with pytest.raises(Unresolvable, match="No source ID.*"):
await dms_source.async_resolve_media(item)
# media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms
with pytest.raises(Unresolvable, match="Invalid media source URI"):
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}//media_id"
)
# Identifier has source_id but no media_id
item = MediaSourceItem(hass, DOMAIN, "source_id/")
with pytest.raises(Unresolvable, match="No media ID.*"):
await dms_source.async_resolve_media(item)
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/source_id/"
)
# Identifier is missing source_id/media_id separator
item = MediaSourceItem(hass, DOMAIN, "source_id")
with pytest.raises(Unresolvable, match="No media ID.*"):
await dms_source.async_resolve_media(item)
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/source_id"
)
# Identifier has an unknown source_id
item = MediaSourceItem(hass, DOMAIN, "unknown_source/media_id")
with pytest.raises(Unresolvable, match="Unknown source ID: unknown_source"):
await dms_source.async_resolve_media(item)
await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/unknown_source/media_id"
)
async def test_resolve_media_success(
hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None
) -> None:
"""Test resolving an item via a DmsDeviceSource."""
object_id = "123"
item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:{object_id}")
res_url = "foo/bar"
res_mime = "audio/mpeg"
@ -112,7 +104,10 @@ async def test_resolve_media_success(
)
dms_device_mock.async_browse_metadata.return_value = didl_item
result = await dms_source.async_resolve_media(item)
result = await media_source.async_resolve_media(
hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}"
)
assert isinstance(result, DidlPlayMedia)
assert result.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}"
assert result.mime_type == res_mime
assert result.didl_metadata is didl_item
@ -131,43 +126,42 @@ async def test_browse_media_unconfigured(hass: HomeAssistant) -> None:
async def test_browse_media_bad_identifier(
hass: HomeAssistant, dms_source: DmsMediaSource
hass: HomeAssistant, device_source_mock: None
) -> None:
"""Test browse_media with a bad source_id."""
item = MediaSourceItem(hass, DOMAIN, "bad-id/media_id")
with pytest.raises(BrowseError, match="Unknown source ID: bad-id"):
await dms_source.async_browse_media(item)
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}/bad-id/media_id"
)
async def test_browse_media_single_source_no_identifier(
hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None
) -> None:
"""Test browse_media without a source_id, with a single device registered."""
# Fast bail-out, mock will be checked after
dms_device_mock.async_browse_metadata.side_effect = UpnpError
# No source_id nor media_id
item = MediaSourceItem(hass, DOMAIN, "")
with pytest.raises(BrowseError):
await dms_source.async_browse_media(item)
await media_source.async_browse_media(hass, f"media-source://{DOMAIN}")
# Mock device should've been browsed for the root directory
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
"0", metadata_filter=ANY
)
# No source_id but a media_id
item = MediaSourceItem(hass, DOMAIN, "/:media-item-id")
# media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms
dms_device_mock.async_browse_metadata.reset_mock()
with pytest.raises(BrowseError):
await dms_source.async_browse_media(item)
# Mock device should've been browsed for the root directory
dms_device_mock.async_browse_metadata.assert_awaited_once_with(
"media-item-id", metadata_filter=ANY
)
with pytest.raises(BrowseError, match="Invalid media source URI"):
await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}//:media-item-id"
)
assert dms_device_mock.async_browse_metadata.await_count == 0
async def test_browse_media_multiple_sources(
hass: HomeAssistant, dms_source: DmsMediaSource, dms_device_mock: Mock
hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None
) -> None:
"""Test browse_media without a source_id, with multiple devices registered."""
# Set up a second source
@ -182,12 +176,12 @@ async def test_browse_media_multiple_sources(
},
title=other_source_title,
)
await hass.config_entries.async_add(other_config_entry)
other_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(other_config_entry.entry_id)
await hass.async_block_till_done()
# No source_id nor media_id
item = MediaSourceItem(hass, DOMAIN, "")
result = await dms_source.async_browse_media(item)
result = await media_source.async_browse_media(hass, f"media-source://{DOMAIN}")
# Mock device should not have been browsed
assert dms_device_mock.async_browse_metadata.await_count == 0
# Result will be a list of available devices
@ -196,31 +190,28 @@ async def test_browse_media_multiple_sources(
assert isinstance(result.children[0], BrowseMediaSource)
assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.children[0].title == MOCK_DEVICE_NAME
assert result.children[0].thumbnail == dms_device_mock.icon
assert isinstance(result.children[1], BrowseMediaSource)
assert result.children[1].identifier == f"{other_source_id}/:0"
assert result.children[1].title == other_source_title
# No source_id but a media_id - will give the exact same list of all devices
item = MediaSourceItem(hass, DOMAIN, "/:media-item-id")
result = await dms_source.async_browse_media(item)
# No source_id but a media_id
# media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms
with pytest.raises(BrowseError, match="Invalid media source URI"):
result = await media_source.async_browse_media(
hass, f"media-source://{DOMAIN}//:media-item-id"
)
# Mock device should not have been browsed
assert dms_device_mock.async_browse_metadata.await_count == 0
# Result will be a list of available devices
assert result.title == "DLNA Servers"
assert result.children
assert isinstance(result.children[0], BrowseMediaSource)
assert result.children[0].identifier == f"{MOCK_SOURCE_ID}/:0"
assert result.children[0].title == MOCK_DEVICE_NAME
assert isinstance(result.children[1], BrowseMediaSource)
assert result.children[1].identifier == f"{other_source_id}/:0"
assert result.children[1].title == other_source_title
# Clean up, to fulfil ssdp_scanner post-condition of every callback being cleared
await hass.config_entries.async_remove(other_config_entry.entry_id)
async def test_browse_media_source_id(
hass: HomeAssistant,
config_entry_mock: MockConfigEntry,
dms_device_mock: Mock,
domain_data_mock: DlnaDmsData,
) -> None:
"""Test browse_media with an explicit source_id."""
# Set up a second device first, then the primary mock device.
@ -235,10 +226,13 @@ async def test_browse_media_source_id(
},
title=other_source_title,
)
await hass.config_entries.async_add(other_config_entry)
await hass.async_block_till_done()
await hass.config_entries.async_add(config_entry_mock)
other_config_entry.add_to_hass(hass)
config_entry_mock.add_to_hass(hass)
# Setting up either config entry will result in the dlna_dms component being
# loaded, and both config entries will be setup
await hass.config_entries.async_setup(other_config_entry.entry_id)
await hass.async_block_till_done()
# Fast bail-out, mock will be checked after