dlna_dms fixes from code review (#67796)
This commit is contained in:
parent
bdb61e0222
commit
62aa7fe10e
11 changed files with 610 additions and 630 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 = "/"
|
||||
|
|
|
@ -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:
|
||||
|
|
27
homeassistant/components/dlna_dms/util.py
Normal file
27
homeassistant/components/dlna_dms/util.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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"] == {}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue