diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index c7b37537e2b..d1d1aad0ce9 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping +from functools import partial +from ipaddress import IPv6Address, ip_address import logging from pprint import pformat from typing import Any, Optional, cast @@ -10,14 +12,16 @@ from urllib.parse import urlparse from async_upnp_client.client import UpnpError from async_upnp_client.profiles.dlna import DmrDevice from async_upnp_client.profiles.profile import find_device_of_type +from getmac import get_mac_address import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_TYPE, CONF_URL -from homeassistant.core import callback +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import IntegrationError +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from .const import ( @@ -56,6 +60,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._udn: str | None = None self._device_type: str | None = None self._name: str | None = None + self._mac: str | None = None self._options: dict[str, Any] = {} @staticmethod @@ -130,32 +135,56 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) - if not discovery_service_list: + if not _is_dmr_device(discovery_info): return self.async_abort(reason="not_dmr") - services = discovery_service_list.get("service") - if not services: - discovery_service_ids: set[str] = set() - elif isinstance(services, list): - discovery_service_ids = {service.get("serviceId") for service in services} - else: - # Only one service defined (etree_to_dict failed to make a list) - discovery_service_ids = {services.get("serviceId")} - - if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids): - return self.async_abort(reason="not_dmr") - - # Abort if another config entry has the same location, in case the - # device doesn't have a static and unique UDN (breaking the UPnP spec). - self._async_abort_entries_match({CONF_URL: self._location}) + # Abort if another config entry has the same location or MAC address, in + # case the device doesn't have a static and unique UDN (breaking the + # UPnP spec). + for entry in self._async_current_entries(include_ignore=True): + if self._location == entry.data[CONF_URL]: + return self.async_abort(reason="already_configured") + if self._mac and self._mac == entry.data.get(CONF_MAC): + return self.async_abort(reason="already_configured") self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() + async def async_step_ignore(self, user_input: Mapping[str, Any]) -> FlowResult: + """Ignore this config flow, and add MAC address as secondary identifier. + + Not all DMR devices correctly implement the spec, so their UDN may + change between boots. Use the MAC address as a secondary identifier so + they can still be ignored in this case. + """ + LOGGER.debug("async_step_ignore: user_input: %s", user_input) + self._udn = user_input["unique_id"] + assert self._udn + await self.async_set_unique_id(self._udn, raise_on_progress=False) + + # Try to get relevant info from SSDP discovery, but don't worry if it's + # not available - the data values will just be None in that case + for dev_type in DmrDevice.DEVICE_TYPES: + discovery = await ssdp.async_get_discovery_info_by_udn_st( + self.hass, self._udn, dev_type + ) + if discovery: + await self._async_set_info_from_discovery( + discovery, abort_if_configured=False + ) + break + + return self.async_create_entry( + title=user_input["title"], + data={ + CONF_URL: self._location, + CONF_DEVICE_ID: self._udn, + CONF_TYPE: self._device_type, + CONF_MAC: self._mac, + }, + ) + async def async_step_unignore(self, user_input: Mapping[str, Any]) -> FlowResult: """Rediscover previously ignored devices by their unique_id.""" LOGGER.debug("async_step_unignore: user_input: %s", user_input) @@ -224,6 +253,9 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not self._name: self._name = device.name + if not self._mac and (host := urlparse(self._location).hostname): + self._mac = await _async_get_mac_address(self.hass, host) + def _create_entry(self) -> FlowResult: """Create a config entry, assuming all required information is now known.""" LOGGER.debug( @@ -238,6 +270,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_URL: self._location, CONF_DEVICE_ID: self._udn, CONF_TYPE: self._device_type, + CONF_MAC: self._mac, } return self.async_create_entry(title=title, data=data, options=self._options) @@ -256,13 +289,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert isinstance(self._location, str) self._udn = discovery_info.ssdp_udn - await self.async_set_unique_id(self._udn) - - if abort_if_configured: - # Abort if already configured, but update the last-known location - self._abort_if_unique_id_configured( - updates={CONF_URL: self._location}, reload_on_update=False - ) + await self.async_set_unique_id(self._udn, raise_on_progress=abort_if_configured) self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st self._name = ( @@ -271,6 +298,17 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): or DEFAULT_NAME ) + if host := discovery_info.ssdp_headers.get("_host"): + self._mac = await _async_get_mac_address(self.hass, host) + + if abort_if_configured: + # Abort if already configured, but update the last-known location + updates = {CONF_URL: self._location} + # Set the MAC address for older entries + if self._mac: + updates[CONF_MAC] = self._mac + self._abort_if_unique_id_configured(updates=updates, reload_on_update=False) + async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" LOGGER.debug("_get_discoveries") @@ -408,3 +446,59 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return True return False + + +def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: + """Determine if discovery is a complete DLNA DMR device. + + Use the discovery_info instead of DmrDevice.is_profile_device to avoid + contacting the device again. + """ + # Abort if the device doesn't support all services required for a DmrDevice. + discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + if not discovery_service_list: + return False + + services = discovery_service_list.get("service") + if not services: + discovery_service_ids: set[str] = set() + elif isinstance(services, list): + discovery_service_ids = {service.get("serviceId") for service in services} + else: + # Only one service defined (etree_to_dict failed to make a list) + discovery_service_ids = {services.get("serviceId")} + + if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids): + return False + + return True + + +async def _async_get_mac_address(hass: HomeAssistant, host: str) -> str | None: + """Get mac address from host name, IPv4 address, or IPv6 address.""" + # Help mypy, which has trouble with the async_add_executor_job + partial call + mac_address: str | None + # getmac has trouble using IPv6 addresses as the "hostname" parameter so + # assume host is an IP address, then handle the case it's not. + try: + ip_addr = ip_address(host) + except ValueError: + mac_address = await hass.async_add_executor_job( + partial(get_mac_address, hostname=host) + ) + else: + if ip_addr.version == 4: + mac_address = await hass.async_add_executor_job( + partial(get_mac_address, ip=host) + ) + else: + # Drop scope_id from IPv6 address by converting via int + ip_addr = IPv6Address(int(ip_addr)) + mac_address = await hass.async_add_executor_job( + partial(get_mac_address, ip6=str(ip_addr)) + ) + + if not mac_address: + return None + + return device_registry.format_mac(mac_address) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 3da3de8434f..0edbc9a9a12 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.32.3"], + "requirements": ["async-upnp-client==0.32.3", "getmac==0.8.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index d2c61e9a318..658adc58ba6 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -28,7 +28,7 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE, CONF_URL +from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -98,6 +98,7 @@ async def async_setup_entry( event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), location=entry.data[CONF_URL], + mac_address=entry.data.get(CONF_MAC), browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False), ) @@ -139,6 +140,7 @@ class DlnaDmrEntity(MediaPlayerEntity): event_callback_url: str | None, poll_availability: bool, location: str, + mac_address: str | None, browse_unfiltered: bool, ) -> None: """Initialize DLNA DMR entity.""" @@ -148,6 +150,7 @@ class DlnaDmrEntity(MediaPlayerEntity): self._event_addr = EventListenAddr(None, event_port, event_callback_url) self.poll_availability = poll_availability self.location = location + self.mac_address = mac_address self.browse_unfiltered = browse_unfiltered self._device_lock = asyncio.Lock() @@ -272,6 +275,11 @@ class DlnaDmrEntity(MediaPlayerEntity): self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) self.browse_unfiltered = entry.options.get(CONF_BROWSE_UNFILTERED, False) + new_mac_address = entry.data.get(CONF_MAC) + if new_mac_address != self.mac_address: + self.mac_address = new_mac_address + self._update_device_registry(set_mac=True) + new_port = entry.options.get(CONF_LISTEN_PORT) or 0 new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) @@ -338,27 +346,42 @@ class DlnaDmrEntity(MediaPlayerEntity): _LOGGER.debug("Error while subscribing during device connect: %r", err) raise - if ( - not self.registry_entry - or not self.registry_entry.config_entry_id - or self.registry_entry.device_id - ): - return + self._update_device_registry() + + def _update_device_registry(self, set_mac: bool = False) -> None: + """Update the device registry with new information about the DMR.""" + if not self._device: + return # Can't get all the required information without a connection + + if not self.registry_entry or not self.registry_entry.config_entry_id: + return # No config registry entry to link to + + if self.registry_entry.device_id and not set_mac: + return # No new information + + connections = set() + # Connections based on the root device's UDN, and the DMR embedded + # device's UDN. They may be the same, if the DMR is the root device. + connections.add( + ( + device_registry.CONNECTION_UPNP, + self._device.profile_device.root_device.udn, + ) + ) + connections.add((device_registry.CONNECTION_UPNP, self._device.udn)) + + if self.mac_address: + # Connection based on MAC address, if known + connections.add( + # Device MAC is obtained from the config entry, which uses getmac + (device_registry.CONNECTION_NETWORK_MAC, self.mac_address) + ) # Create linked HA DeviceEntry now the information is known. dev_reg = device_registry.async_get(self.hass) device_entry = dev_reg.async_get_or_create( config_entry_id=self.registry_entry.config_entry_id, - # Connections are based on the root device's UDN, and the DMR - # embedded device's UDN. They may be the same, if the DMR is the - # root device. - connections={ - ( - device_registry.CONNECTION_UPNP, - self._device.profile_device.root_device.udn, - ), - (device_registry.CONNECTION_UPNP, self._device.udn), - }, + connections=connections, identifiers={(DOMAIN, self.unique_id)}, default_manufacturer=self._device.manufacturer, default_model=self._device.model_name, diff --git a/requirements_all.txt b/requirements_all.txt index fef70a81154..ad9c3726be4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -761,6 +761,7 @@ georss_ign_sismologia_client==0.3 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.5 +# homeassistant.components.dlna_dmr # homeassistant.components.kef # homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 959be9a8c25..eed6a539a9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,6 +574,7 @@ georss_ign_sismologia_client==0.3 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.5 +# homeassistant.components.dlna_dmr # homeassistant.components.kef # homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 521f770a8fa..81225173d51 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -11,22 +11,23 @@ import pytest from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN from homeassistant.components.dlna_dmr.data import DlnaDmrData -from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE, CONF_URL +from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -MOCK_DEVICE_BASE_URL = "http://192.88.99.4" -MOCK_DEVICE_LOCATION = MOCK_DEVICE_BASE_URL + "/dmr_description.xml" +MOCK_DEVICE_HOST_ADDR = "198.51.100.4" +MOCK_DEVICE_LOCATION = f"http://{MOCK_DEVICE_HOST_ADDR}/dmr_description.xml" MOCK_DEVICE_NAME = "Test Renderer Device" MOCK_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1" MOCK_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-58b275c52f1e" MOCK_DEVICE_USN = f"{MOCK_DEVICE_UDN}::{MOCK_DEVICE_TYPE}" +MOCK_MAC_ADDRESS = "ab:cd:ef:01:02:03" -LOCAL_IP = "192.88.99.1" -EVENT_CALLBACK_URL = "http://192.88.99.1/notify" +LOCAL_IP = "198.51.100.1" +EVENT_CALLBACK_URL = "http://198.51.100.1/notify" -NEW_DEVICE_LOCATION = "http://192.88.99.7" + "/dmr_description.xml" +NEW_DEVICE_LOCATION = "http://198.51.100.7" + "/dmr_description.xml" @pytest.fixture @@ -80,6 +81,24 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: @pytest.fixture def config_entry_mock() -> MockConfigEntry: """Mock a config entry for this platform.""" + mock_entry = MockConfigEntry( + unique_id=MOCK_DEVICE_UDN, + domain=DLNA_DOMAIN, + data={ + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, + }, + title=MOCK_DEVICE_NAME, + options={}, + ) + return mock_entry + + +@pytest.fixture +def config_entry_mock_no_mac() -> MockConfigEntry: + """Mock a config entry that does not already contain a MAC address.""" mock_entry = MockConfigEntry( unique_id=MOCK_DEVICE_UDN, domain=DLNA_DOMAIN, @@ -105,7 +124,7 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: device.profile_device = ( domain_data_mock.upnp_factory.async_create_device.return_value ) - device.media_image_url = "http://192.88.99.20:8200/AlbumArt/2624-17620.jpg" + device.media_image_url = "http://198.51.100.20:8200/AlbumArt/2624-17620.jpg" device.udn = "device_udn" device.manufacturer = "device_manufacturer" device.model_name = "device_model_name" diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 8035b7ee822..c3251cd31a2 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -1,8 +1,9 @@ """Test the DLNA config flow.""" from __future__ import annotations +from collections.abc import Iterable import dataclasses -from unittest.mock import Mock +from unittest.mock import Mock, patch from async_upnp_client.client import UpnpDevice from async_upnp_client.exceptions import UpnpError @@ -17,14 +18,16 @@ from homeassistant.components.dlna_dmr.const import ( CONF_POLL_AVAILABILITY, DOMAIN as DLNA_DOMAIN, ) -from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_TYPE, CONF_URL +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from .conftest import ( + MOCK_DEVICE_HOST_ADDR, MOCK_DEVICE_LOCATION, MOCK_DEVICE_NAME, MOCK_DEVICE_TYPE, MOCK_DEVICE_UDN, + MOCK_MAC_ADDRESS, NEW_DEVICE_LOCATION, ) @@ -37,6 +40,8 @@ pytestmark = [ ] WRONG_DEVICE_TYPE = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +CHANGED_DEVICE_LOCATION = "http://198.51.100.55/dmr_description.xml" +CHANGED_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-badbadbadbad" MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" @@ -45,6 +50,7 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, + ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR}, upnp={ ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, @@ -79,6 +85,16 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ) +@pytest.fixture(autouse=True) +def mock_get_mac_address() -> Iterable[Mock]: + """Mock the get_mac_address function to prevent network access and assist tests.""" + with patch( + "homeassistant.components.dlna_dmr.config_flow.get_mac_address", autospec=True + ) as gma_mock: + gma_mock.return_value = MOCK_MAC_ADDRESS + yield gma_mock + + async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: """Test user-init'd flow, no discovered devices, user entering a valid URL.""" result = await hass.config_entries.flow.async_init( @@ -98,6 +114,7 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {CONF_POLL_AVAILABILITY: True} @@ -140,6 +157,7 @@ async def test_user_flow_discovered_manual( CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {CONF_POLL_AVAILABILITY: True} @@ -172,6 +190,7 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {} @@ -235,6 +254,7 @@ async def test_user_flow_embedded_st( CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {CONF_POLL_AVAILABILITY: True} @@ -285,6 +305,7 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None: CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {} @@ -318,6 +339,7 @@ async def test_ssdp_flow_unavailable( CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {} @@ -348,10 +370,80 @@ async def test_ssdp_flow_existing( async def test_ssdp_flow_duplicate_location( - hass: HomeAssistant, config_entry_mock: MockConfigEntry + hass: HomeAssistant, config_entry_mock: MockConfigEntry, mock_get_mac_address: Mock ) -> None: """Test that discovery of device with URL matching existing entry gets aborted.""" + # Prevent matching based on MAC address + mock_get_mac_address.return_value = None config_entry_mock.add_to_hass(hass) + + # New discovery with different UDN but same location + discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_udn=CHANGED_DEVICE_UDN) + 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"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION + + +async def test_ssdp_duplicate_mac_ignored_entry( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test SSDP with different UDN but matching MAC for ignored config entry is ignored.""" + # Add an ignored entry + config_entry_mock.source = config_entries.SOURCE_IGNORE + config_entry_mock.add_to_hass(hass) + + # Prevent matching based on location or UDN + discovery = dataclasses.replace( + MOCK_DISCOVERY, + ssdp_location=CHANGED_DEVICE_LOCATION, + ssdp_udn=CHANGED_DEVICE_UDN, + ) + + # SSDP discovery should be aborted + 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"] == "already_configured" + + +async def test_ssdp_duplicate_mac_configured_entry( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test SSDP with different UDN but matching MAC for existing entry is ignored.""" + config_entry_mock.add_to_hass(hass) + + # Prevent matching based on location or UDN + discovery = dataclasses.replace( + MOCK_DISCOVERY, + ssdp_location=CHANGED_DEVICE_LOCATION, + ssdp_udn=CHANGED_DEVICE_UDN, + ) + + # SSDP discovery should be aborted + 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"] == "already_configured" + + +async def test_ssdp_add_mac( + hass: HomeAssistant, config_entry_mock_no_mac: MockConfigEntry +) -> None: + """Test adding of MAC to existing entry that didn't have one.""" + config_entry_mock_no_mac.add_to_hass(hass) + + # Start a discovery that adds the MAC address (due to auto-use mock_get_mac_address) result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -359,7 +451,31 @@ async def test_ssdp_flow_duplicate_location( ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION + await hass.async_block_till_done() + + # Config entry should be updated to have a MAC address + assert config_entry_mock_no_mac.data[CONF_MAC] == MOCK_MAC_ADDRESS + + +async def test_ssdp_dont_remove_mac( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """SSDP with failure to resolve MAC should not remove MAC from config entry.""" + config_entry_mock.add_to_hass(hass) + + # Start a discovery that fails when resolving the MAC + mock_get_mac_address.return_value = None + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + # Config entry should still have a MAC address + assert config_entry_mock.data[CONF_MAC] == MOCK_MAC_ADDRESS async def test_ssdp_flow_upnp_udn( @@ -497,9 +613,62 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: assert result["reason"] == "alternative_integration" +async def test_ignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: + """Test ignoring an SSDP discovery fills in config entry data from SSDP.""" + # Device found via SSDP, matching the 2nd device type tried + ssdp_scanner_mock.async_get_discovery_info_by_udn_st.side_effect = [ + None, + MOCK_DISCOVERY, + None, + None, + None, + ] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": MOCK_DEVICE_UDN, "title": MOCK_DEVICE_NAME}, + ) + 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_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, + } + + +async def test_ignore_flow_no_ssdp( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test ignoring a flow without SSDP info still creates a config entry.""" + # Nothing found from SSDP + ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": MOCK_DEVICE_UDN, "title": MOCK_DEVICE_NAME}, + ) + 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: None, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: None, + CONF_MAC: None, + } + + async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: """Test a config flow started by unignoring a device.""" - # Create ignored entry + # Create ignored entry (with no extra info from SSDP) + ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_IGNORE}, @@ -509,7 +678,6 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME - assert result["data"] == {} # Device was found via SSDP, matching the 2nd device type tried ssdp_scanner_mock.async_get_discovery_info_by_udn_st.side_effect = [ @@ -540,6 +708,7 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No CONF_URL: MOCK_DEVICE_LOCATION, CONF_DEVICE_ID: MOCK_DEVICE_UDN, CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, } assert result["options"] == {} @@ -551,7 +720,8 @@ async def test_unignore_flow_offline( hass: HomeAssistant, ssdp_scanner_mock: Mock ) -> None: """Test a config flow started by unignoring a device, but the device is offline.""" - # Create ignored entry + # Create ignored entry (with no extra info from SSDP) + ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_IGNORE}, @@ -561,7 +731,6 @@ async def test_unignore_flow_offline( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME - assert result["data"] == {} # Device is not in the SSDP discoveries (perhaps HA restarted between ignore and unignore) ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None @@ -576,6 +745,74 @@ async def test_unignore_flow_offline( assert result["reason"] == "discovery_error" +async def test_get_mac_address_ipv4( + hass: HomeAssistant, mock_get_mac_address: Mock +) -> None: + """Test getting MAC address from IPv4 address for SSDP discovery.""" + # Init'ing the flow should be enough to get the MAC address + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + mock_get_mac_address.assert_called_once_with(ip=MOCK_DEVICE_HOST_ADDR) + + +async def test_get_mac_address_ipv6( + hass: HomeAssistant, mock_get_mac_address: Mock +) -> None: + """Test getting MAC address from IPv6 address for SSDP discovery.""" + # Use a scoped link-local IPv6 address for the host + IPV6_HOST_UNSCOPED = "fe80::1ff:fe23:4567:890a" + IPV6_HOST = f"{IPV6_HOST_UNSCOPED}%eth2" + IPV6_DEVICE_LOCATION = f"http://{IPV6_HOST}/dmr_description.xml" + discovery = dataclasses.replace(MOCK_DISCOVERY, ssdp_location=IPV6_DEVICE_LOCATION) + discovery.ssdp_headers = dict(discovery.ssdp_headers) + discovery.ssdp_headers["_host"] = IPV6_HOST + + # Init'ing the flow should be enough to get the MAC address + 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_FORM + assert result["step_id"] == "confirm" + + # The scope must be removed for get_mac_address to work correctly + mock_get_mac_address.assert_called_once_with(ip6=IPV6_HOST_UNSCOPED) + + +async def test_get_mac_address_host( + hass: HomeAssistant, mock_get_mac_address: Mock +) -> None: + """Test getting MAC address from hostname for manual location entry.""" + # Create device via manual URL entry, so that it must be contacted directly, + # not via the ssdp component. + DEVICE_HOSTNAME = "local-dmr" + DEVICE_LOCATION = f"http://{DEVICE_HOSTNAME}/dmr_description.xml" + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: DEVICE_LOCATION} + ) + assert result["data"] == { + CONF_URL: DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + await hass.async_block_till_done() + + mock_get_mac_address.assert_called_once_with(hostname=DEVICE_HOSTNAME) + + async def test_options_flow( hass: HomeAssistant, config_entry_mock: MockConfigEntry ) -> None: diff --git a/tests/components/dlna_dmr/test_data.py b/tests/components/dlna_dmr/test_data.py index 5b2c0b1815c..06d5e01558c 100644 --- a/tests/components/dlna_dmr/test_data.py +++ b/tests/components/dlna_dmr/test_data.py @@ -73,7 +73,7 @@ async def test_event_notifier( # Different address should give different notifier listen_addr_3 = EventListenAddr( - "192.88.99.4", 9999, "http://192.88.99.4:9999/notify" + "198.51.100.4", 9999, "http://198.51.100.4:9999/notify" ) event_notifier_3 = await domain_data.async_get_event_notifier(listen_addr_3, hass) assert event_notifier_3 is not None @@ -82,8 +82,8 @@ async def test_event_notifier( # Check that the parameters were passed through to the AiohttpNotifyServer aiohttp_notify_servers_mock.assert_called_with( requester=ANY, - source=("192.88.99.4", 9999), - callback_url="http://192.88.99.4:9999/notify", + source=("198.51.100.4", 9999), + callback_url="http://198.51.100.4:9999/notify", loop=ANY, ) diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 0091826db84..e9c6bbfda15 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -40,9 +40,18 @@ from homeassistant.components.media_player import ( const as mp_const, ) from homeassistant.components.media_source import DOMAIN as MS_DOMAIN, PlayMedia -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_MAC, + CONF_TYPE, + CONF_URL, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get as async_get_dr +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + async_get as async_get_dr, +) from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_registry import ( async_entries_for_config_entry, @@ -57,6 +66,7 @@ from .conftest import ( MOCK_DEVICE_TYPE, MOCK_DEVICE_UDN, MOCK_DEVICE_USN, + MOCK_MAC_ADDRESS, NEW_DEVICE_LOCATION, ) @@ -269,7 +279,7 @@ async def test_setup_entry_with_options( config_entry_mock.options = MappingProxyType( { CONF_LISTEN_PORT: 2222, - CONF_CALLBACK_URL_OVERRIDE: "http://192.88.99.10/events", + CONF_CALLBACK_URL_OVERRIDE: "http://198.51.100.10/events", CONF_POLL_AVAILABILITY: True, } ) @@ -283,7 +293,7 @@ async def test_setup_entry_with_options( ) # Check event notifiers are acquired with the configured port and callback URL domain_data_mock.async_get_event_notifier.assert_awaited_once_with( - EventListenAddr(LOCAL_IP, 2222, "http://192.88.99.10/events"), hass + EventListenAddr(LOCAL_IP, 2222, "http://198.51.100.10/events"), hass ) # Check UPnP services are subscribed dmr_device_mock.async_subscribe_services.assert_awaited_once_with( @@ -324,6 +334,40 @@ async def test_setup_entry_with_options( assert mock_state.state == ha_const.STATE_UNAVAILABLE +async def test_setup_entry_mac_address( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + ssdp_scanner_mock: Mock, + dmr_device_mock: Mock, +) -> None: + """Entry with a MAC address will set up and set the device registry connection.""" + await setup_mock_component(hass, config_entry_mock) + + # Check the device registry connections for MAC address + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections + + +async def test_setup_entry_no_mac_address( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock_no_mac: MockConfigEntry, + ssdp_scanner_mock: Mock, + dmr_device_mock: Mock, +) -> None: + """Test setting up an entry without a MAC address will succeed.""" + await setup_mock_component(hass, config_entry_mock_no_mac) + + # Check the device registry connections does not include the MAC address + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections + + async def test_event_subscribe_failure( hass: HomeAssistant, config_entry_mock: MockConfigEntry, dmr_device_mock: Mock ) -> None: @@ -630,21 +674,21 @@ async def test_play_media_stopped( { ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/17621.mp3", mp_const.ATTR_MEDIA_ENQUEUE: False, }, blocking=True, ) dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( - media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_url="http://198.51.100.20:8200/MediaItems/17621.mp3", media_title="Home Assistant", override_upnp_class="object.item.audioItem.musicTrack", meta_data={}, ) dmr_device_mock.async_stop.assert_awaited_once_with() dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( - "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY + "http://198.51.100.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY ) dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() dmr_device_mock.async_play.assert_awaited_once_with() @@ -662,21 +706,21 @@ async def test_play_media_playing( { ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/17621.mp3", mp_const.ATTR_MEDIA_ENQUEUE: False, }, blocking=True, ) dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( - media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_url="http://198.51.100.20:8200/MediaItems/17621.mp3", media_title="Home Assistant", override_upnp_class="object.item.audioItem.musicTrack", meta_data={}, ) dmr_device_mock.async_stop.assert_not_awaited() dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( - "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY + "http://198.51.100.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY ) dmr_device_mock.async_wait_for_can_play.assert_not_awaited() dmr_device_mock.async_play.assert_not_awaited() @@ -695,7 +739,7 @@ async def test_play_media_no_autoplay( { ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/17621.mp3", mp_const.ATTR_MEDIA_ENQUEUE: False, mp_const.ATTR_MEDIA_EXTRA: {"autoplay": False}, }, @@ -703,14 +747,14 @@ async def test_play_media_no_autoplay( ) dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( - media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_url="http://198.51.100.20:8200/MediaItems/17621.mp3", media_title="Home Assistant", override_upnp_class="object.item.audioItem.musicTrack", meta_data={}, ) dmr_device_mock.async_stop.assert_awaited_once_with() dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( - "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY + "http://198.51.100.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY ) dmr_device_mock.async_wait_for_can_play.assert_not_awaited() dmr_device_mock.async_play.assert_not_awaited() @@ -726,11 +770,11 @@ async def test_play_media_metadata( { ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/17621.mp3", mp_const.ATTR_MEDIA_ENQUEUE: False, mp_const.ATTR_MEDIA_EXTRA: { "title": "Mock song", - "thumb": "http://192.88.99.20:8200/MediaItems/17621.jpg", + "thumb": "http://198.51.100.20:8200/MediaItems/17621.jpg", "metadata": {"artist": "Mock artist", "album": "Mock album"}, }, }, @@ -738,13 +782,13 @@ async def test_play_media_metadata( ) dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( - media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_url="http://198.51.100.20:8200/MediaItems/17621.mp3", media_title="Mock song", override_upnp_class="object.item.audioItem.musicTrack", meta_data={ "artist": "Mock artist", "album": "Mock album", - "album_art_uri": "http://192.88.99.20:8200/MediaItems/17621.jpg", + "album_art_uri": "http://198.51.100.20:8200/MediaItems/17621.jpg", }, ) @@ -756,7 +800,7 @@ async def test_play_media_metadata( { ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.TVSHOW, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/123.mkv", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/123.mkv", mp_const.ATTR_MEDIA_ENQUEUE: False, mp_const.ATTR_MEDIA_EXTRA: { "title": "Mock show", @@ -767,7 +811,7 @@ async def test_play_media_metadata( ) dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( - media_url="http://192.88.99.20:8200/MediaItems/123.mkv", + media_url="http://198.51.100.20:8200/MediaItems/123.mkv", media_title="Mock show", override_upnp_class="object.item.videoItem.videoBroadcast", meta_data={"episodeSeason": 1, "episodeNumber": 12}, @@ -1236,7 +1280,7 @@ async def test_unavailable_device( mp_const.SERVICE_PLAY_MEDIA, { mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_CONTENT_ID: "http://198.51.100.20:8200/MediaItems/17621.mp3", mp_const.ATTR_MEDIA_ENQUEUE: False, }, ), @@ -2146,3 +2190,39 @@ async def test_config_update_poll_availability( mock_state = hass.states.get(mock_entity_id) assert mock_state is not None assert mock_state.state == MediaPlayerState.IDLE + + +async def test_config_update_mac_address( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock_no_mac: MockConfigEntry, + ssdp_scanner_mock: Mock, + dmr_device_mock: Mock, +) -> None: + """Test discovering the MAC address post-setup will update the device registry.""" + await setup_mock_component(hass, config_entry_mock_no_mac) + + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Check the device registry connections does not include the MAC address + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections + + # MAC address discovered and set by config flow + hass.config_entries.async_update_entry( + config_entry_mock_no_mac, + data={ + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + CONF_MAC: MOCK_MAC_ADDRESS, + }, + ) + await hass.async_block_till_done() + + # Device registry connections should now include the MAC address + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections