diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index ee81d1be88f4f040eecf5af168dba3fa227a3331..454a28c9f7d52255f1ea0eeaab52d5a2e626e8e2 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -216,6 +216,19 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if _is_ignored_device(discovery_info): return self.async_abort(reason="alternative_integration") + # Abort if the device doesn't support all services required for a DmrDevice. + # Use the discovery_info instead of DmrDevice.is_profile_device to avoid + # contacting the device again. + discovery_service_list = discovery_info.get(ssdp.ATTR_UPNP_SERVICE_LIST) + if not discovery_service_list: + return self.async_abort(reason="not_dmr") + discovery_service_ids = { + service.get("serviceId") + for service in discovery_service_list.get("service") or [] + } + if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids): + return self.async_abort(reason="not_dmr") + # Abort if a migration flow for the device's location is in progress for progress in self._async_in_progress(include_uninitialized=True): if progress["context"].get("unique_id") == self._location: @@ -277,10 +290,10 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except UpnpError as err: raise ConnectError("cannot_connect") from err - try: - device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) - except UpnpError as err: - raise ConnectError("not_dmr") from err + if not DmrDevice.is_profile_device(device): + raise ConnectError("not_dmr") + + device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) if not self._udn: self._udn = device.udn diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index ac6a35194fe9b2e401347ef28a64c7e237d14e98..ac77009e0cb615d03157474e56c040a24122fe7d 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -30,11 +30,11 @@ "discovery_error": "Failed to discover a matching DLNA device", "incomplete_config": "Configuration is missing a required variable", "non_unique_id": "Multiple devices found with the same unique ID", - "not_dmr": "Device is not a Digital Media Renderer" + "not_dmr": "Device is not a supported Digital Media Renderer" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "not_dmr": "Device is not a Digital Media Renderer" + "not_dmr": "Device is not a supported Digital Media Renderer" } }, "options": { diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json index 6711a86134426d2493101acd607031b447d2a67c..512dfe7f11c48d861ec6893dd550b3ae4a123c44 100644 --- a/homeassistant/components/dlna_dmr/translations/en.json +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -8,12 +8,12 @@ "discovery_error": "Failed to discover a matching DLNA device", "incomplete_config": "Configuration is missing a required variable", "non_unique_id": "Multiple devices found with the same unique ID", - "not_dmr": "Device is not a Digital Media Renderer" + "not_dmr": "Device is not a supported Digital Media Renderer" }, "error": { "cannot_connect": "Failed to connect", "could_not_connect": "Failed to connect to DLNA device", - "not_dmr": "Device is not a Digital Media Renderer" + "not_dmr": "Device is not a supported Digital Media Renderer" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f5f4feaa70a1f6cbac6bd5998003953915f352dc..c937f21036843159577a33a5e0b8edebdaad080d 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -51,6 +51,7 @@ ATTR_UPNP_MODEL_NAME = "modelName" ATTR_UPNP_MODEL_NUMBER = "modelNumber" ATTR_UPNP_MODEL_URL = "modelURL" ATTR_UPNP_SERIAL = "serialNumber" +ATTR_UPNP_SERVICE_LIST = "serviceList" ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 71910ec1cd8232b9330164b97e3de4ba069b535a..3a9025a9a29c90d971d9b07440c9051b7e622470 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -5,7 +5,7 @@ from collections.abc import Iterable from socket import AddressFamily # pylint: disable=no-name-in-module from unittest.mock import Mock, create_autospec, patch, seal -from async_upnp_client import UpnpDevice, UpnpFactory +from async_upnp_client import UpnpDevice, UpnpFactory, UpnpService import pytest from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN @@ -49,6 +49,26 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: upnp_device.parent_device = None upnp_device.root_device = upnp_device upnp_device.all_devices = [upnp_device] + upnp_device.services = { + "urn:schemas-upnp-org:service:AVTransport:1": create_autospec( + UpnpService, + instance=True, + service_type="urn:schemas-upnp-org:service:AVTransport:1", + service_id="urn:upnp-org:serviceId:AVTransport", + ), + "urn:schemas-upnp-org:service:ConnectionManager:1": create_autospec( + UpnpService, + instance=True, + service_type="urn:schemas-upnp-org:service:ConnectionManager:1", + service_id="urn:upnp-org:serviceId:ConnectionManager", + ), + "urn:schemas-upnp-org:service:RenderingControl:1": create_autospec( + UpnpService, + instance=True, + service_type="urn:schemas-upnp-org:service:RenderingControl:1", + service_id="urn:upnp-org:serviceId:RenderingControl", + ), + } seal(upnp_device) domain_data.upnp_factory.async_create_device.return_value = upnp_device diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 245d97be4aae5dee33e7f09808164b6ea728b075..5a2327ecce91b325323af48d76465bcbbf2c46b6 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import DiscoveryInfoType from .conftest import ( MOCK_DEVICE_LOCATION, @@ -51,13 +52,38 @@ MOCK_CONFIG_IMPORT_DATA = { MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY = { +MOCK_DISCOVERY: DiscoveryInfoType = { ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ssdp.ATTR_UPNP_SERVICE_LIST: { + "service": [ + { + "SCPDURL": "/AVTransport/scpd.xml", + "controlURL": "/AVTransport/control.xml", + "eventSubURL": "/AVTransport/event.xml", + "serviceId": "urn:upnp-org:serviceId:AVTransport", + "serviceType": "urn:schemas-upnp-org:service:AVTransport:1", + }, + { + "SCPDURL": "/ConnectionManager/scpd.xml", + "controlURL": "/ConnectionManager/control.xml", + "eventSubURL": "/ConnectionManager/event.xml", + "serviceId": "urn:upnp-org:serviceId:ConnectionManager", + "serviceType": "urn:schemas-upnp-org:service:ConnectionManager:1", + }, + { + "SCPDURL": "/RenderingControl/scpd.xml", + "controlURL": "/RenderingControl/control.xml", + "eventSubURL": "/RenderingControl/event.xml", + "serviceId": "urn:upnp-org:serviceId:RenderingControl", + "serviceType": "urn:schemas-upnp-org:service:RenderingControl:1", + }, + ] + }, ssdp.ATTR_HA_MATCHING_DOMAINS: {DLNA_DOMAIN}, } @@ -197,6 +223,8 @@ async def test_user_flow_embedded_st( embedded_device.udn = MOCK_DEVICE_UDN embedded_device.device_type = MOCK_DEVICE_TYPE embedded_device.name = MOCK_DEVICE_NAME + embedded_device.services = upnp_device.services + upnp_device.services = {} upnp_device.all_devices.append(embedded_device) result = await hass.config_entries.flow.async_init( @@ -552,9 +580,38 @@ async def test_ssdp_flow_upnp_udn( assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION +async def test_ssdp_missing_services(hass: HomeAssistant) -> None: + """Test SSDP ignores devices that are missing required services.""" + # No services defined at all + discovery = dict(MOCK_DISCOVERY) + del discovery[ssdp.ATTR_UPNP_SERVICE_LIST] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + # AVTransport service is missing + discovery = dict(MOCK_DISCOVERY) + discovery[ssdp.ATTR_UPNP_SERVICE_LIST] = { + "service": [ + service + for service in discovery[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" + ] + } + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: """Test SSDP discovery ignores certain devices.""" - discovery = MOCK_DISCOVERY.copy() + discovery = dict(MOCK_DISCOVERY) discovery[ssdp.ATTR_HA_MATCHING_DOMAINS] = {DLNA_DOMAIN, "other_domain"} result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -564,7 +621,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "alternative_integration" - discovery = MOCK_DISCOVERY.copy() + discovery = dict(MOCK_DISCOVERY) discovery[ssdp.ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN,