Match config entries for dlna_dmr based on device MAC, in addition to UDN (#74619)

* Factor out _is_dmr_device function

* Use DMR device's MAC to match existing config entries

Some DMR devices change their every time they boot, against the DMR specs.
Try to match such devices to existing config entries by using their MAC
addresses.

* Add DMR device's MAC as a device_registry connection

* Use doc-only IPs (RFC5737) for dlna_dmr tests
This commit is contained in:
Michael Chisholm
2022-12-12 05:40:35 +11:00
committed by GitHub
parent 1f6e2511f8
commit fbab7413a5
9 changed files with 539 additions and 84 deletions

View File

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

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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