From a28fd7d61b392d001b338f3bff2d5d68f5d50b4f Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Tue, 28 Sep 2021 06:47:01 +1000 Subject: [PATCH] Config-flow for DLNA-DMR integration (#55267) * Modernize dlna_dmr component: configflow, test, types * Support config-flow with ssdp discovery * Add unit tests * Enforce strict typing * Gracefully handle network devices (dis)appearing * Fix Aiohttp mock response headers type to match actual response class * Fixes from code review * Fixes from code review * Import device config in flow if unavailable at hass start * Support SSDP advertisements * Ignore bad BOOTID, fix ssdp:byebye handling * Only listen for events on interface connected to device * Release all listeners when entities are removed * Warn about deprecated dlna_dmr configuration * Use sublogger for dlna_dmr.config_flow for easier filtering * Tests for dlna_dmr.data module * Rewrite DMR tests for HA style * Fix DMR strings: "Digital Media *Renderer*" * Update DMR entity state and device info when changed * Replace deprecated async_upnp_client State with TransportState * supported_features are dynamic, based on current device state * Cleanup fully when subscription fails * Log warnings when device connection fails unexpectedly * Set PARALLEL_UPDATES to unlimited * Fix spelling * Fixes from code review * Simplify has & can checks to just can, which includes has * Treat transitioning state as playing (not idle) to reduce UI jerking * Test if device is usable * Handle ssdp:update messages properly * Fix _remove_ssdp_callbacks being shared by all DlnaDmrEntity instances * Fix tests for transitioning state * Mock DmrDevice.is_profile_device (added to support embedded devices) * Use ST & NT SSDP headers to find DMR devices, not deviceType The deviceType is extracted from the device's description XML, and will not be what we want when dealing with embedded devices. * Use UDN from SSDP headers, not device description, as unique_id The SSDP headers have the UDN of the embedded device that we're interested in, whereas the device description (`ATTR_UPNP_UDN`) field will always be for the root device. * Fix DMR string English localization * Test config flow with UDN from SSDP headers * Bump async-upnp-client==0.22.1, fix flake8 error * fix test for remapping * DMR HA Device connections based on root and embedded UDN * DmrDevice's UpnpDevice is now named profile_device * Use device type from SSDP headers, not device description * Mark dlna_dmr constants as Final * Use embedded device UDN and type for unique ID when connected via URL * More informative connection error messages * Also match SSDP messages on NT headers The NT header is to ssdp:alive messages what ST is to M-SEARCH responses. * Bump async-upnp-client==0.22.2 * fix merge * Bump async-upnp-client==0.22.3 Co-authored-by: Steven Looman Co-authored-by: J. Nick Koston --- .coveragerc | 1 - .strict-typing | 1 + CODEOWNERS | 1 + .../components/discovery/__init__.py | 5 +- homeassistant/components/dlna_dmr/__init__.py | 55 + .../components/dlna_dmr/config_flow.py | 340 +++++ homeassistant/components/dlna_dmr/const.py | 16 + homeassistant/components/dlna_dmr/data.py | 126 ++ .../components/dlna_dmr/manifest.json | 27 +- .../components/dlna_dmr/media_player.py | 691 ++++++--- .../components/dlna_dmr/strings.json | 44 + .../components/dlna_dmr/translations/en.json | 44 + homeassistant/components/ssdp/__init__.py | 8 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- .../components/yeelight/manifest.json | 2 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 20 + homeassistant/package_constraints.txt | 2 +- mypy.ini | 11 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dlna_dmr/__init__.py | 1 + tests/components/dlna_dmr/conftest.py | 141 ++ tests/components/dlna_dmr/test_config_flow.py | 624 ++++++++ tests/components/dlna_dmr/test_data.py | 121 ++ tests/components/dlna_dmr/test_init.py | 59 + .../components/dlna_dmr/test_media_player.py | 1338 +++++++++++++++++ tests/components/ssdp/test_init.py | 10 +- tests/test_util/aiohttp.py | 3 +- 30 files changed, 3443 insertions(+), 257 deletions(-) create mode 100644 homeassistant/components/dlna_dmr/config_flow.py create mode 100644 homeassistant/components/dlna_dmr/const.py create mode 100644 homeassistant/components/dlna_dmr/data.py create mode 100644 homeassistant/components/dlna_dmr/strings.json create mode 100644 homeassistant/components/dlna_dmr/translations/en.json create mode 100644 tests/components/dlna_dmr/__init__.py create mode 100644 tests/components/dlna_dmr/conftest.py create mode 100644 tests/components/dlna_dmr/test_config_flow.py create mode 100644 tests/components/dlna_dmr/test_data.py create mode 100644 tests/components/dlna_dmr/test_init.py create mode 100644 tests/components/dlna_dmr/test_media_player.py diff --git a/.coveragerc b/.coveragerc index 0a54f15724c..e21ea37fe06 100644 --- a/.coveragerc +++ b/.coveragerc @@ -212,7 +212,6 @@ omit = homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/switch.py - homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* homeassistant/components/doods/* diff --git a/.strict-typing b/.strict-typing index 2a982a16afc..091ee3b8b2c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -31,6 +31,7 @@ homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* +homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* diff --git a/CODEOWNERS b/CODEOWNERS index 6e178069816..cea06b6b361 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,6 +122,7 @@ homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek +homeassistant/components/dlna_dmr/* @StevenLooman @chishm homeassistant/components/doorbird/* @oblogic7 @bdraco homeassistant/components/dsmr/* @Robbie1221 @frenck homeassistant/components/dsmr_reader/* @depl0y diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 99106ef63a8..3a925fb0579 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -1,4 +1,6 @@ """Starts a service to scan in intervals for new devices.""" +from __future__ import annotations + from datetime import timedelta import json import logging @@ -56,7 +58,7 @@ SERVICE_HANDLERS = { "lg_smart_device": ("media_player", "lg_soundbar"), } -OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} +OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} MIGRATED_SERVICE_HANDLERS = [ SERVICE_APPLE_TV, @@ -64,6 +66,7 @@ MIGRATED_SERVICE_HANDLERS = [ "deconz", SERVICE_DAIKIN, "denonavr", + SERVICE_DLNA_DMR, "esphome", "google_cast", SERVICE_HASS_IOS_APP, diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index f38456ec6ee..536567336fd 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -1 +1,56 @@ """The dlna_dmr component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, LOGGER + +PLATFORMS = [MEDIA_PLAYER_DOMAIN] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up DLNA component.""" + if MEDIA_PLAYER_DOMAIN not in config: + return True + + for entry_config in config[MEDIA_PLAYER_DOMAIN]: + if entry_config.get(CONF_PLATFORM) != DOMAIN: + continue + LOGGER.warning( + "Configuring dlna_dmr via yaml is deprecated; the configuration for" + " %s has been migrated to a config entry and can be safely removed", + entry_config.get(CONF_URL), + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_config, + ) + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up a DLNA DMR device from a config entry.""" + LOGGER.debug("Setting up config entry: %s", entry.unique_id) + + # Forward setup to the appropriate platform + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + # Forward to the same platform as async_setup_entry did + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py new file mode 100644 index 00000000000..53513d593f5 --- /dev/null +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -0,0 +1,340 @@ +"""Config flow for DLNA DMR.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from pprint import pformat +from typing import Any, Mapping, Optional +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 +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_TYPE, CONF_URL +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import IntegrationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN, +) +from .data import get_domain_data + +LOGGER = logging.getLogger(__name__) + +FlowInput = Optional[Mapping[str, Any]] + + +class ConnectError(IntegrationError): + """Error occurred when trying to connect to a device.""" + + +class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a DLNA DMR config flow. + + The Unique Device Name (UDN) of the DMR device is used as the unique_id for + config entries and for entities. This UDN may differ from the root UDN if + the DMR is an embedded device. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self._discoveries: list[Mapping[str, str]] = [] + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Define the config flow to handle options.""" + return DlnaDmrOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: FlowInput = None) -> FlowResult: + """Handle a flow initialized by the user: manual URL entry. + + Discovered devices will already be displayed, no need to prompt user + with them here. + """ + LOGGER.debug("async_step_user: user_input: %s", user_input) + + errors = {} + if user_input is not None: + try: + discovery = await self._async_connect(user_input[CONF_URL]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + # If unmigrated config was imported earlier then use it + import_data = get_domain_data(self.hass).unmigrated_config.get( + user_input[CONF_URL] + ) + if import_data is not None: + return await self.async_step_import(import_data) + # Device setup manually, assume we don't get SSDP broadcast notifications + options = {CONF_POLL_AVAILABILITY: True} + return await self._async_create_entry_from_discovery(discovery, options) + + data_schema = vol.Schema({CONF_URL: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_data: FlowInput = None) -> FlowResult: + """Import a new DLNA DMR device from a config entry. + + This flow is triggered by `async_setup`. If no device has been + configured before, find any matching device and create a config_entry + for it. Otherwise, do nothing. + """ + LOGGER.debug("async_step_import: import_data: %s", import_data) + + if not import_data or CONF_URL not in import_data: + LOGGER.debug("Entry not imported: incomplete_config") + return self.async_abort(reason="incomplete_config") + + self._async_abort_entries_match({CONF_URL: import_data[CONF_URL]}) + + location = import_data[CONF_URL] + self._discoveries = await self._async_get_discoveries() + + poll_availability = True + + # Find the device in the list of unconfigured devices + for discovery in self._discoveries: + if discovery[ssdp.ATTR_SSDP_LOCATION] == location: + # Device found via SSDP, it shouldn't need polling + poll_availability = False + LOGGER.debug( + "Entry %s found via SSDP, with UDN %s", + import_data[CONF_URL], + discovery[ssdp.ATTR_SSDP_UDN], + ) + break + else: + # Not in discoveries. Try connecting directly. + try: + discovery = await self._async_connect(location) + except ConnectError as err: + LOGGER.debug( + "Entry %s not imported: %s", import_data[CONF_URL], err.args[0] + ) + # Store the config to apply if the device is added later + get_domain_data(self.hass).unmigrated_config[location] = import_data + return self.async_abort(reason=err.args[0]) + + # Set options from the import_data, except listen_ip which is no longer used + options = { + CONF_LISTEN_PORT: import_data.get(CONF_LISTEN_PORT), + CONF_CALLBACK_URL_OVERRIDE: import_data.get(CONF_CALLBACK_URL_OVERRIDE), + CONF_POLL_AVAILABILITY: poll_availability, + } + + # Override device name if it's set in the YAML + if CONF_NAME in import_data: + discovery = dict(discovery) + discovery[ssdp.ATTR_UPNP_FRIENDLY_NAME] = import_data[CONF_NAME] + + LOGGER.debug("Entry %s ready for import", import_data[CONF_URL]) + return await self._async_create_entry_from_discovery(discovery, options) + + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle a flow initialized by SSDP discovery.""" + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + + self._discoveries = [discovery_info] + + udn = discovery_info[ssdp.ATTR_SSDP_UDN] + location = discovery_info[ssdp.ATTR_SSDP_LOCATION] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured( + updates={CONF_URL: location}, reload_on_update=False + ) + + # If the device needs migration because it wasn't turned on when HA + # started, silently migrate it now. + import_data = get_domain_data(self.hass).unmigrated_config.get(location) + if import_data is not None: + return await self.async_step_import(import_data) + + parsed_url = urlparse(location) + name = discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + self.context["title_placeholders"] = {"name": name} + + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input: FlowInput = None) -> FlowResult: + """Allow the user to confirm adding the device. + + Also check that the device is still available, otherwise when it is + added to HA it won't report the correct DeviceInfo. + """ + LOGGER.debug("async_step_confirm: %s", user_input) + + errors = {} + if user_input is not None: + discovery = self._discoveries[0] + try: + await self._async_connect(discovery[ssdp.ATTR_SSDP_LOCATION]) + except ConnectError as err: + errors["base"] = err.args[0] + else: + return await self._async_create_entry_from_discovery(discovery) + + self._set_confirm_only() + return self.async_show_form(step_id="confirm", errors=errors) + + async def _async_create_entry_from_discovery( + self, + discovery: Mapping[str, Any], + options: Mapping[str, Any] | None = None, + ) -> FlowResult: + """Create an entry from discovery.""" + LOGGER.debug("_async_create_entry_from_discovery: discovery: %s", discovery) + + location = discovery[ssdp.ATTR_SSDP_LOCATION] + udn = discovery[ssdp.ATTR_SSDP_UDN] + + # Abort if already configured, but update the last-known location + await self.async_set_unique_id(udn) + self._abort_if_unique_id_configured(updates={CONF_URL: location}) + + parsed_url = urlparse(location) + title = discovery.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname + + data = { + CONF_URL: discovery[ssdp.ATTR_SSDP_LOCATION], + CONF_DEVICE_ID: discovery[ssdp.ATTR_SSDP_UDN], + CONF_TYPE: discovery.get(ssdp.ATTR_SSDP_NT) or discovery[ssdp.ATTR_SSDP_ST], + } + return self.async_create_entry(title=title, data=data, options=options) + + async def _async_get_discoveries(self) -> list[Mapping[str, str]]: + """Get list of unconfigured DLNA devices discovered by SSDP.""" + LOGGER.debug("_get_discoveries") + + # Get all compatible devices from ssdp's cache + discoveries: list[Mapping[str, str]] = [] + for udn_st in DmrDevice.DEVICE_TYPES: + st_discoveries = await ssdp.async_get_discovery_info_by_st( + self.hass, udn_st + ) + discoveries.extend(st_discoveries) + + # Filter out devices already configured + current_unique_ids = { + entry.unique_id for entry in self._async_current_entries() + } + discoveries = [ + disc + for disc in discoveries + if disc[ssdp.ATTR_SSDP_UDN] not in current_unique_ids + ] + + return discoveries + + async def _async_connect(self, location: str) -> dict[str, str]: + """Connect to a device to confirm it works and get discovery information. + + Raises ConnectError if something goes wrong. + """ + LOGGER.debug("_async_connect(location=%s)", location) + domain_data = get_domain_data(self.hass) + try: + device = await domain_data.upnp_factory.async_create_device(location) + except UpnpError as err: + raise ConnectError("could_not_connect") from err + + try: + device = find_device_of_type(device, DmrDevice.DEVICE_TYPES) + except UpnpError as err: + raise ConnectError("not_dmr") from err + + discovery = { + ssdp.ATTR_SSDP_LOCATION: location, + ssdp.ATTR_SSDP_UDN: device.udn, + ssdp.ATTR_SSDP_ST: device.device_type, + ssdp.ATTR_UPNP_FRIENDLY_NAME: device.name, + } + + return discovery + + +class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a DLNA DMR options flow. + + Configures the single instance and updates the existing config entry. + """ + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + # Don't modify existing (read-only) options -- copy and update instead + options = dict(self.config_entry.options) + + if user_input is not None: + LOGGER.debug("user_input: %s", user_input) + listen_port = user_input.get(CONF_LISTEN_PORT) or None + callback_url_override = user_input.get(CONF_CALLBACK_URL_OVERRIDE) or None + + try: + # Cannot use cv.url validation in the schema itself so apply + # extra validation here + if callback_url_override: + cv.url(callback_url_override) + except vol.Invalid: + errors["base"] = "invalid_url" + + options[CONF_LISTEN_PORT] = listen_port + options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override + options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] + + # Save if there's no errors, else fall through and show the form again + if not errors: + return self.async_create_entry(title="", data=options) + + fields = {} + + def _add_with_suggestion(key: str, validator: Callable) -> None: + """Add a field to with a suggested, not default, value.""" + suggested_value = options.get(key) + if suggested_value is None: + fields[vol.Optional(key)] = validator + else: + fields[ + vol.Optional(key, description={"suggested_value": suggested_value}) + ] = validator + + # listen_port can be blank or 0 for "bind any free port" + _add_with_suggestion(CONF_LISTEN_PORT, cv.port) + _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) + fields[ + vol.Required( + CONF_POLL_AVAILABILITY, + default=options.get(CONF_POLL_AVAILABILITY, False), + ) + ] = bool + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(fields), + errors=errors, + ) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py new file mode 100644 index 00000000000..7b081469ca8 --- /dev/null +++ b/homeassistant/components/dlna_dmr/const.py @@ -0,0 +1,16 @@ +"""Constants for the DLNA DMR component.""" + +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "dlna_dmr" + +CONF_LISTEN_PORT: Final = "listen_port" +CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" +CONF_POLL_AVAILABILITY: Final = "poll_availability" + +DEFAULT_NAME: Final = "DLNA Digital Media Renderer" + +CONNECT_TIMEOUT: Final = 10 diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py new file mode 100644 index 00000000000..8d4693dd435 --- /dev/null +++ b/homeassistant/components/dlna_dmr/data.py @@ -0,0 +1,126 @@ +"""Data used by this integration.""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, NamedTuple, cast + +from async_upnp_client import UpnpEventHandler, UpnpFactory, UpnpRequester +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN, LOGGER + + +class EventListenAddr(NamedTuple): + """Unique identifier for an event listener.""" + + host: str | None # Specific local IP(v6) address for listening on + port: int # Listening port, 0 means use an ephemeral port + callback_url: str | None + + +class DlnaDmrData: + """Storage class for domain global data.""" + + lock: asyncio.Lock + requester: UpnpRequester + upnp_factory: UpnpFactory + event_notifiers: dict[EventListenAddr, AiohttpNotifyServer] + event_notifier_refs: defaultdict[EventListenAddr, int] + stop_listener_remove: CALLBACK_TYPE | None = None + unmigrated_config: dict[str, Mapping[str, Any]] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global data.""" + self.lock = asyncio.Lock() + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + self.requester = AiohttpSessionRequester(session, with_sleep=False) + self.upnp_factory = UpnpFactory(self.requester, non_strict=True) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + self.unmigrated_config = {} + + async def async_cleanup_event_notifiers(self, event: Event) -> None: + """Clean up resources when Home Assistant is stopped.""" + del event # unused + LOGGER.debug("Cleaning resources in DlnaDmrData") + async with self.lock: + tasks = (server.stop_server() for server in self.event_notifiers.values()) + asyncio.gather(*tasks) + self.event_notifiers = {} + self.event_notifier_refs = defaultdict(int) + + async def async_get_event_notifier( + self, listen_addr: EventListenAddr, hass: HomeAssistant + ) -> UpnpEventHandler: + """Return existing event notifier for the listen_addr, or create one. + + Only one event notify server is kept for each listen_addr. Must call + async_release_event_notifier when done to cleanup resources. + """ + LOGGER.debug("Getting event handler for %s", listen_addr) + + async with self.lock: + # Stop all servers when HA shuts down, to release resources on devices + if not self.stop_listener_remove: + self.stop_listener_remove = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_cleanup_event_notifiers + ) + + # Always increment the reference counter, for existing or new event handlers + self.event_notifier_refs[listen_addr] += 1 + + # Return an existing event handler if we can + if listen_addr in self.event_notifiers: + return self.event_notifiers[listen_addr].event_handler + + # Start event handler + server = AiohttpNotifyServer( + requester=self.requester, + listen_port=listen_addr.port, + listen_host=listen_addr.host, + callback_url=listen_addr.callback_url, + loop=hass.loop, + ) + await server.start_server() + LOGGER.debug("Started event handler at %s", server.callback_url) + + self.event_notifiers[listen_addr] = server + + return server.event_handler + + async def async_release_event_notifier(self, listen_addr: EventListenAddr) -> None: + """Indicate that the event notifier for listen_addr is not used anymore. + + This is called once by each caller of async_get_event_notifier, and will + stop the listening server when all users are done. + """ + async with self.lock: + assert self.event_notifier_refs[listen_addr] > 0 + self.event_notifier_refs[listen_addr] -= 1 + + # Shutdown the server when it has no more users + if self.event_notifier_refs[listen_addr] == 0: + server = self.event_notifiers.pop(listen_addr) + await server.stop_server() + + # Remove the cleanup listener when there's nothing left to cleanup + if not self.event_notifiers: + assert self.stop_listener_remove is not None + self.stop_listener_remove() + self.stop_listener_remove = None + + +def get_domain_data(hass: HomeAssistant) -> DlnaDmrData: + """Obtain this integration's domain data, creating it if needed.""" + if DOMAIN in hass.data: + return cast(DlnaDmrData, hass.data[DOMAIN]) + + data = DlnaDmrData(hass) + hass.data[DOMAIN] = data + return data diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 1295a1d221b..2e802ee876f 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -1,9 +1,30 @@ { "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.22.1"], - "dependencies": ["network"], - "codeowners": [], + "requirements": ["async-upnp-client==0.22.3"], + "dependencies": ["network", "ssdp"], + "ssdp": [ + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ], + "codeowners": ["@StevenLooman", "@chishm"], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 36f62155b2d..d7db104ee42 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -2,16 +2,19 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta import functools -import logging +from typing import Any, Callable, TypeVar, cast -import aiohttp -from async_upnp_client import UpnpFactory -from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester -from async_upnp_client.profiles.dlna import DeviceState, DmrDevice +from async_upnp_client import UpnpError, UpnpService, UpnpStateVariable +from async_upnp_client.const import NotificationSubType +from async_upnp_client.profiles.dlna import DmrDevice, TransportState +from async_upnp_client.utils import async_get_local_ip import voluptuous as vol +from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, @@ -24,298 +27,499 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( + CONF_DEVICE_ID, CONF_NAME, + CONF_TYPE, CONF_URL, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DEFAULT_NAME, + DOMAIN, + LOGGER as _LOGGER, +) +from .data import EventListenAddr, get_domain_data -DLNA_DMR_DATA = "dlna_dmr" - -DEFAULT_NAME = "DLNA Digital Media Renderer" -DEFAULT_LISTEN_PORT = 8301 +PARALLEL_UPDATES = 0 +# Configuration via YAML is deprecated in favour of config flow CONF_LISTEN_IP = "listen_ip" -CONF_LISTEN_PORT = "listen_port" -CONF_CALLBACK_URL_OVERRIDE = "callback_url_override" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_LISTEN_IP): cv.string, - vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_URL), + cv.deprecated(CONF_LISTEN_IP), + cv.deprecated(CONF_LISTEN_PORT), + cv.deprecated(CONF_NAME), + cv.deprecated(CONF_CALLBACK_URL_OVERRIDE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, + } + ), ) - -def catch_request_errors(): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - - def call_wrapper(func): - """Call wrapper for decorator.""" - - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" - try: - return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error during call %s", func.__name__) - - return wrapper - - return call_wrapper +Func = TypeVar("Func", bound=Callable[..., Any]) -async def async_start_event_handler( +def catch_request_errors(func: Func) -> Func: + """Catch UpnpError errors.""" + + @functools.wraps(func) + async def wrapper(self: "DlnaDmrEntity", *args: Any, **kwargs: Any) -> Any: + """Catch UpnpError errors and check availability before and after request.""" + if not self.available: + _LOGGER.warning( + "Device disappeared when trying to call service %s", func.__name__ + ) + return + try: + return await func(self, *args, **kwargs) + except UpnpError as err: + self.check_available = True + _LOGGER.error("Error during call %s: %r", func.__name__, err) + + return cast(Func, wrapper) + + +async def async_setup_entry( hass: HomeAssistant, - server_host: str, - server_port: int, - requester, - callback_url_override: str | None = None, -): - """Register notify view.""" - hass_data = hass.data[DLNA_DMR_DATA] - if "event_handler" in hass_data: - return hass_data["event_handler"] + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DlnaDmrEntity from a config entry.""" + del hass # Unused + _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) - # start event handler - server = AiohttpNotifyServer( - requester, - listen_port=server_port, - listen_host=server_host, - callback_url=callback_url_override, + # Create our own device-wrapping entity + entity = DlnaDmrEntity( + udn=entry.data[CONF_DEVICE_ID], + device_type=entry.data[CONF_TYPE], + name=entry.title, + event_port=entry.options.get(CONF_LISTEN_PORT) or 0, + event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), + poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), + location=entry.data[CONF_URL], ) - await server.start_server() - _LOGGER.info("UPNP/DLNA event handler listening, url: %s", server.callback_url) - hass_data["notify_server"] = server - hass_data["event_handler"] = server.event_handler - # register for graceful shutdown - async def async_stop_server(event): - """Stop server.""" - _LOGGER.debug("Stopping UPNP/DLNA event handler") - await server.stop_server() + entry.async_on_unload( + entry.add_update_listener(entity.async_config_update_listener) + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) - - return hass_data["event_handler"] + async_add_entities([entity]) -async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up DLNA DMR platform.""" - if config.get(CONF_URL) is not None: - url = config[CONF_URL] - name = config.get(CONF_NAME) - elif discovery_info is not None: - url = discovery_info["ssdp_description"] - name = discovery_info.get("name") +class DlnaDmrEntity(MediaPlayerEntity): + """Representation of a DLNA DMR device as a HA entity.""" - if DLNA_DMR_DATA not in hass.data: - hass.data[DLNA_DMR_DATA] = {} + udn: str + device_type: str - if "lock" not in hass.data[DLNA_DMR_DATA]: - hass.data[DLNA_DMR_DATA]["lock"] = asyncio.Lock() + _event_addr: EventListenAddr + poll_availability: bool + # Last known URL for the device, used when adding this entity to hass to try + # to connect before SSDP has rediscovered it, or when SSDP discovery fails. + location: str - # build upnp/aiohttp requester - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True) + _device_lock: asyncio.Lock # Held when connecting or disconnecting the device + _device: DmrDevice | None = None + _remove_ssdp_callbacks: list[Callable] + check_available: bool = False - # ensure event handler has been started - async with hass.data[DLNA_DMR_DATA]["lock"]: - server_host = config.get(CONF_LISTEN_IP) - if server_host is None: - server_host = await async_get_source_ip(hass, PUBLIC_TARGET_IP) - server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) - callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) - event_handler = await async_start_event_handler( - hass, server_host, server_port, requester, callback_url_override + # Track BOOTID in SSDP advertisements for device changes + _bootid: int | None = None + + # DMR devices need polling for track position information. async_update will + # determine whether further device polling is required. + _attr_should_poll = True + + def __init__( + self, + udn: str, + device_type: str, + name: str, + event_port: int, + event_callback_url: str | None, + poll_availability: bool, + location: str, + ) -> None: + """Initialize DLNA DMR entity.""" + self.udn = udn + self.device_type = device_type + self._attr_name = name + self._event_addr = EventListenAddr(None, event_port, event_callback_url) + self.poll_availability = poll_availability + self.location = location + self._device_lock = asyncio.Lock() + self._remove_ssdp_callbacks = [] + + async def async_added_to_hass(self) -> None: + """Handle addition.""" + # Try to connect to the last known location, but don't worry if not available + if not self._device: + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + + # Get SSDP notifications for only this device + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, self.async_ssdp_callback, {"USN": self.usn} + ) ) - # create upnp device - factory = UpnpFactory(requester, non_strict=True) - try: - upnp_device = await factory.async_create_device(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - raise PlatformNotReady() from err + # async_upnp_client.SsdpListener only reports byebye once for each *UDN* + # (device name) which often is not the USN (service within the device) + # that we're interested in. So also listen for byebye advertisements for + # the UDN, which is reported in the _udn field of the combined_headers. + self._remove_ssdp_callbacks.append( + await ssdp.async_register_callback( + self.hass, + self.async_ssdp_callback, + {"_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE}, + ) + ) - # wrap with DmrDevice - dlna_device = DmrDevice(upnp_device, event_handler) + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + for callback in self._remove_ssdp_callbacks: + callback() + self._remove_ssdp_callbacks.clear() + await self._device_disconnect() - # create our own device - device = DlnaDmrDevice(dlna_device, name) - _LOGGER.debug("Adding device: %s", device) - async_add_entities([device], True) - - -class DlnaDmrDevice(MediaPlayerEntity): - """Representation of a DLNA DMR device.""" - - def __init__(self, dmr_device, name=None): - """Initialize DLNA DMR device.""" - self._device = dmr_device - self._name = name - - self._available = False - self._subscription_renew_time = None - - async def async_added_to_hass(self): - """Handle addition.""" - self._device.on_event = self._on_event - - # Register unsubscribe on stop - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) - - @property - def available(self): - """Device is available.""" - return self._available - - async def _async_on_hass_stop(self, event): - """Event handler on Home Assistant stop.""" - async with self.hass.data[DLNA_DMR_DATA]["lock"]: - await self._device.async_unsubscribe_services() - - async def async_update(self): - """Retrieve the latest data.""" - was_available = self._available + async def async_ssdp_callback( + self, info: Mapping[str, Any], change: ssdp.SsdpChange + ) -> None: + """Handle notification from SSDP of device state change.""" + _LOGGER.debug( + "SSDP %s notification of device %s at %s", + change, + info[ssdp.ATTR_SSDP_USN], + info.get(ssdp.ATTR_SSDP_LOCATION), + ) try: - await self._device.async_update() - self._available = True - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Device unavailable") + bootid_str = info[ssdp.ATTR_SSDP_BOOTID] + bootid: int | None = int(bootid_str, 10) + except (KeyError, ValueError): + bootid = None + + if change == ssdp.SsdpChange.UPDATE: + # This is an announcement that bootid is about to change + if self._bootid is not None and self._bootid == bootid: + # Store the new value (because our old value matches) so that we + # can ignore subsequent ssdp:alive messages + try: + next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID] + self._bootid = int(next_bootid_str, 10) + except (KeyError, ValueError): + pass + # Nothing left to do until ssdp:alive comes through return - # do we need to (re-)subscribe? - now = dt_util.utcnow() - should_renew = ( - self._subscription_renew_time and now >= self._subscription_renew_time - ) - if should_renew or not was_available and self._available: - try: - timeout = await self._device.async_subscribe_services() - self._subscription_renew_time = dt_util.utcnow() + timeout / 2 - except (asyncio.TimeoutError, aiohttp.ClientError): - self._available = False - _LOGGER.debug("Could not (re)subscribe") + if self._bootid is not None and self._bootid != bootid and self._device: + # Device has rebooted, drop existing connection and maybe reconnect + await self._device_disconnect() + self._bootid = bootid - def _on_event(self, service, state_variables): + if change == ssdp.SsdpChange.BYEBYE and self._device: + # Device is going away, disconnect + await self._device_disconnect() + + if change == ssdp.SsdpChange.ALIVE and not self._device: + location = info[ssdp.ATTR_SSDP_LOCATION] + try: + await self._device_connect(location) + except UpnpError as err: + _LOGGER.warning( + "Failed connecting to recently alive device at %s: %r", + location, + err, + ) + + # Device could have been de/re-connected, state probably changed + self.schedule_update_ha_state() + + async def async_config_update_listener( + self, hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Handle options update by modifying self in-place.""" + del hass # Unused + _LOGGER.debug( + "Updating: %s with data=%s and options=%s", + self.name, + entry.data, + entry.options, + ) + self.location = entry.data[CONF_URL] + self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) + + new_port = entry.options.get(CONF_LISTEN_PORT) or 0 + new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) + + if ( + new_port == self._event_addr.port + and new_callback_url == self._event_addr.callback_url + ): + return + + # Changes to eventing requires a device reconnect for it to update correctly + await self._device_disconnect() + # Update _event_addr after disconnecting, to stop the right event listener + self._event_addr = self._event_addr._replace( + port=new_port, callback_url=new_callback_url + ) + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.warning("Couldn't (re)connect after config change: %r", err) + + # Device was de/re-connected, state might have changed + self.schedule_update_ha_state() + + async def _device_connect(self, location: str) -> None: + """Connect to the device now that it's available.""" + _LOGGER.debug("Connecting to device at %s", location) + + async with self._device_lock: + if self._device: + _LOGGER.debug("Trying to connect when device already connected") + return + + domain_data = get_domain_data(self.hass) + + # Connect to the base UPNP device + upnp_device = await domain_data.upnp_factory.async_create_device(location) + + # Create/get event handler that is reachable by the device, using + # the connection's local IP to listen only on the relevant interface + _, event_ip = await async_get_local_ip(location, self.hass.loop) + self._event_addr = self._event_addr._replace(host=event_ip) + event_handler = await domain_data.async_get_event_notifier( + self._event_addr, self.hass + ) + + # Create profile wrapper + self._device = DmrDevice(upnp_device, event_handler) + + self.location = location + + # Subscribe to event notifications + try: + self._device.on_event = self._on_event + await self._device.async_subscribe_services(auto_resubscribe=True) + except UpnpError as err: + # Don't leave the device half-constructed + self._device.on_event = None + self._device = None + await domain_data.async_release_event_notifier(self._event_addr) + _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 + + # 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), + }, + identifiers={(DOMAIN, self.unique_id)}, + default_manufacturer=self._device.manufacturer, + default_model=self._device.model_name, + default_name=self._device.name, + ) + + # Update entity registry to link to the device + ent_reg = entity_registry.async_get(self.hass) + ent_reg.async_get_or_create( + self.registry_entry.domain, + self.registry_entry.platform, + self.unique_id, + device_id=device_entry.id, + ) + + async def _device_disconnect(self) -> None: + """Destroy connections to the device now that it's not available. + + Also call when removing this entity from hass to clean up connections. + """ + async with self._device_lock: + if not self._device: + _LOGGER.debug("Disconnecting from device that's not connected") + return + + _LOGGER.debug("Disconnecting from %s", self._device.name) + + self._device.on_event = None + old_device = self._device + self._device = None + await old_device.async_unsubscribe_services() + + domain_data = get_domain_data(self.hass) + await domain_data.async_release_event_notifier(self._event_addr) + + @property + def available(self) -> bool: + """Device is available when we have a connection to it.""" + return self._device is not None and self._device.profile_device.available + + async def async_update(self) -> None: + """Retrieve the latest data.""" + if not self._device: + if not self.poll_availability: + return + try: + await self._device_connect(self.location) + except UpnpError: + return + + assert self._device is not None + + try: + do_ping = self.poll_availability or self.check_available + await self._device.async_update(do_ping=do_ping) + except UpnpError: + _LOGGER.debug("Device unavailable") + await self._device_disconnect() + return + finally: + self.check_available = False + + def _on_event( + self, service: UpnpService, state_variables: Sequence[UpnpStateVariable] + ) -> None: """State variable(s) changed, let home-assistant know.""" + del service # Unused + if not state_variables: + # Indicates a failure to resubscribe, check if device is still available + self.check_available = True self.schedule_update_ha_state() @property - def supported_features(self): - """Flag media player features that are supported.""" + def supported_features(self) -> int: + """Flag media player features that are supported at this moment. + + Supported features may change as the device enters different states. + """ + if not self._device: + return 0 + supported_features = 0 if self._device.has_volume_level: supported_features |= SUPPORT_VOLUME_SET if self._device.has_volume_mute: supported_features |= SUPPORT_VOLUME_MUTE - if self._device.has_play: + if self._device.can_play: supported_features |= SUPPORT_PLAY - if self._device.has_pause: + if self._device.can_pause: supported_features |= SUPPORT_PAUSE - if self._device.has_stop: + if self._device.can_stop: supported_features |= SUPPORT_STOP - if self._device.has_previous: + if self._device.can_previous: supported_features |= SUPPORT_PREVIOUS_TRACK - if self._device.has_next: + if self._device.can_next: supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA - if self._device.has_seek_rel_time: + if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK return supported_features @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._device.has_volume_level: - return self._device.volume_level - return 0 + if not self._device or not self._device.has_volume_level: + return None + return self._device.volume_level - @catch_request_errors() - async def async_set_volume_level(self, volume): + @catch_request_errors + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" + assert self._device is not None await self._device.async_set_volume_level(volume) @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" + if not self._device: + return None return self._device.is_volume_muted - @catch_request_errors() - async def async_mute_volume(self, mute): + @catch_request_errors + async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" + assert self._device is not None desired_mute = bool(mute) await self._device.async_mute_volume(desired_mute) - @catch_request_errors() - async def async_media_pause(self): + @catch_request_errors + async def async_media_pause(self) -> None: """Send pause command.""" - if not self._device.can_pause: - _LOGGER.debug("Cannot do Pause") - return - + assert self._device is not None await self._device.async_pause() - @catch_request_errors() - async def async_media_play(self): + @catch_request_errors + async def async_media_play(self) -> None: """Send play command.""" - if not self._device.can_play: - _LOGGER.debug("Cannot do Play") - return - + assert self._device is not None await self._device.async_play() - @catch_request_errors() - async def async_media_stop(self): + @catch_request_errors + async def async_media_stop(self) -> None: """Send stop command.""" - if not self._device.can_stop: - _LOGGER.debug("Cannot do Stop") - return - + assert self._device is not None await self._device.async_stop() - @catch_request_errors() - async def async_media_seek(self, position): + @catch_request_errors + async def async_media_seek(self, position: int | float) -> None: """Send seek command.""" - if not self._device.can_seek_rel_time: - _LOGGER.debug("Cannot do Seek/rel_time") - return - + assert self._device is not None time = timedelta(seconds=position) await self._device.async_seek_rel_time(time) - @catch_request_errors() - async def async_play_media(self, media_type, media_id, **kwargs): + @catch_request_errors + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) title = "Home Assistant" + assert self._device is not None + # Stop current playing media if self._device.can_stop: await self.async_media_stop() @@ -325,81 +529,90 @@ class DlnaDmrDevice(MediaPlayerEntity): await self._device.async_wait_for_can_play() # If already playing, no need to call Play - if self._device.state == DeviceState.PLAYING: + if self._device.transport_state == TransportState.PLAYING: return # Play it await self.async_media_play() - @catch_request_errors() - async def async_media_previous_track(self): + @catch_request_errors + async def async_media_previous_track(self) -> None: """Send previous track command.""" - if not self._device.can_previous: - _LOGGER.debug("Cannot do Previous") - return - + assert self._device is not None await self._device.async_previous() - @catch_request_errors() - async def async_media_next_track(self): + @catch_request_errors + async def async_media_next_track(self) -> None: """Send next track command.""" - if not self._device.can_next: - _LOGGER.debug("Cannot do Next") - return - + assert self._device is not None await self._device.async_next() @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" + if not self._device: + return None return self._device.media_title @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" + if not self._device: + return None return self._device.media_image_url @property - def state(self): + def state(self) -> str: """State of the player.""" - if not self._available: + if not self._device or not self.available: return STATE_OFF - - if self._device.state is None: + if self._device.transport_state is None: return STATE_ON - if self._device.state == DeviceState.PLAYING: + if self._device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): return STATE_PLAYING - if self._device.state == DeviceState.PAUSED: + if self._device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): return STATE_PAUSED + if self._device.transport_state == TransportState.VENDOR_DEFINED: + return STATE_UNKNOWN return STATE_IDLE @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" + if not self._device: + return None return self._device.media_duration @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" + if not self._device: + return None return self._device.media_position @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid. Returns value from homeassistant.util.dt.utcnow(). """ + if not self._device: + return None return self._device.media_position_updated_at @property - def name(self) -> str: - """Return the name of the device.""" - if self._name: - return self._name - return self._device.name + def unique_id(self) -> str: + """Report the UDN (Unique Device Name) as this entity's unique ID.""" + return self.udn @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self._device.udn + def usn(self) -> str: + """Get the USN based on the UDN (Unique Device Name) and device type.""" + return f"{self.udn}::{self.device_type}" diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json new file mode 100644 index 00000000000..27e96b465db --- /dev/null +++ b/homeassistant/components/dlna_dmr/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "DLNA Digital Media Renderer", + "description": "URL to a device description XML file", + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "could_not_connect": "Failed to connect to DLNA device", + "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" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + } + }, + "options": { + "step": { + "init": { + "title": "DLNA Digital Media Renderer configuration", + "data": { + "listen_port": "Event listener port (random if not set)", + "callback_url_override": "Event listener callback URL", + "poll_availability": "Poll for device availability" + } + } + }, + "error": { + "invalid_url": "Invalid URL" + } + } +} diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json new file mode 100644 index 00000000000..94bbd365e18 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "could_not_connect": "Failed to connect to DLNA device", + "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" + }, + "error": { + "could_not_connect": "Failed to connect to DLNA device", + "not_dmr": "Device is not a Digital Media Renderer" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "user": { + "data": { + "url": "URL" + }, + "description": "URL to a device description XML file", + "title": "DLNA Digital Media Renderer" + } + } + }, + "options": { + "error": { + "invalid_url": "Invalid URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Event listener callback URL", + "listen_port": "Event listener port (random if not set)", + "poll_availability": "Poll for device availability" + }, + "title": "DLNA Digital Media Renderer configuration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3bfde32e50f..b06f1b34493 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -39,9 +39,13 @@ IPV4_BROADCAST = IPv4Address("255.255.255.255") # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" ATTR_SSDP_ST = "ssdp_st" +ATTR_SSDP_NT = "ssdp_nt" +ATTR_SSDP_UDN = "ssdp_udn" ATTR_SSDP_USN = "ssdp_usn" ATTR_SSDP_EXT = "ssdp_ext" ATTR_SSDP_SERVER = "ssdp_server" +ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" +ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description ATTR_UPNP_DEVICE_TYPE = "deviceType" ATTR_UPNP_FRIENDLY_NAME = "friendlyName" @@ -56,7 +60,7 @@ ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" -PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE] +PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE, "nt"] DISCOVERY_MAPPING = { "usn": ATTR_SSDP_USN, @@ -64,6 +68,8 @@ DISCOVERY_MAPPING = { "server": ATTR_SSDP_SERVER, "st": ATTR_SSDP_ST, "location": ATTR_SSDP_LOCATION, + "_udn": ATTR_SSDP_UDN, + "nt": ATTR_SSDP_NT, } SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 465c1ce3cbf..6590e6fa756 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.22.1"], + "requirements": ["async-upnp-client==0.22.3"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 7cf45673292..fb5912657c0 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.1"], + "requirements": ["async-upnp-client==0.22.3"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 90e575fb404..3ecece7414f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.1"], + "requirements": ["yeelight==0.7.5", "async-upnp-client==0.22.3"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 78dc71976e6..c69815d5e6c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -61,6 +61,7 @@ FLOWS = [ "dexcom", "dialogflow", "directv", + "dlna_dmr", "doorbird", "dsmr", "dunehd", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e823b404a..b058f972229 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -83,6 +83,26 @@ SSDP = { "manufacturer": "DIRECTV" } ], + "dlna_dmr": [ + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "st": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "nt": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ], "fritz": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2cf735c435d..8b0802b26a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.8.1 diff --git a/mypy.ini b/mypy.ini index 53afb687afe..1d84658657d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -352,6 +352,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dlna_dmr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dnsip.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 805d29da384..518bbf0868b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -327,7 +327,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7db1f7d5127..4272bb87506 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -221,7 +221,7 @@ arcam-fmj==0.7.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.1 +async-upnp-client==0.22.3 # homeassistant.components.aurora auroranoaa==0.0.2 diff --git a/tests/components/dlna_dmr/__init__.py b/tests/components/dlna_dmr/__init__.py new file mode 100644 index 00000000000..a1f4ccc2ba7 --- /dev/null +++ b/tests/components/dlna_dmr/__init__.py @@ -0,0 +1 @@ +"""Tests for the DLNA component.""" diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py new file mode 100644 index 00000000000..521a1c22fa5 --- /dev/null +++ b/tests/components/dlna_dmr/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for DLNA tests.""" +from __future__ import annotations + +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 +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.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_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}" + +LOCAL_IP = "192.88.99.1" +EVENT_CALLBACK_URL = "http://192.88.99.1/notify" + +NEW_DEVICE_LOCATION = "http://192.88.99.7" + "/dmr_description.xml" + + +@pytest.fixture +def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: + """Mock the global data used by this component. + + This includes network clients and library object factories. Mocking it + prevents network use. + """ + domain_data = create_autospec(DlnaDmrData, instance=True) + domain_data.upnp_factory = create_autospec( + UpnpFactory, spec_set=True, instance=True + ) + + upnp_device = create_autospec(UpnpDevice, instance=True) + upnp_device.name = MOCK_DEVICE_NAME + upnp_device.udn = MOCK_DEVICE_UDN + upnp_device.device_url = MOCK_DEVICE_LOCATION + upnp_device.device_type = "urn:schemas-upnp-org:device:MediaRenderer:1" + upnp_device.available = True + upnp_device.parent_device = None + upnp_device.root_device = upnp_device + upnp_device.all_devices = [upnp_device] + seal(upnp_device) + domain_data.upnp_factory.async_create_device.return_value = upnp_device + + domain_data.unmigrated_config = {} + + with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): + yield domain_data + + # Make sure the event notifiers are released + assert ( + domain_data.async_get_event_notifier.await_count + == domain_data.async_release_event_notifier.await_count + ) + + +@pytest.fixture +def config_entry_mock() -> Iterable[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, + }, + title=MOCK_DEVICE_NAME, + options={}, + ) + yield mock_entry + + +@pytest.fixture +def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: + """Mock the async_upnp_client DMR device, initially connected.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as constructor: + device = constructor.return_value + device.on_event = None + 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.udn = "device_udn" + device.manufacturer = "device_manufacturer" + device.model_name = "device_model_name" + device.name = "device_name" + + yield device + + # Make sure the device is disconnected + assert ( + device.async_subscribe_services.await_count + == device.async_unsubscribe_services.await_count + ) + + assert device.on_event is None + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture() -> Iterable[None]: + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(autouse=True) +def ssdp_scanner_mock() -> Iterable[Mock]: + """Mock the SSDP module.""" + with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: + reg_callback = mock_scanner.return_value.async_register_callback + reg_callback.return_value = Mock(return_value=None) + yield mock_scanner.return_value + assert ( + reg_callback.call_count == reg_callback.return_value.call_count + ), "Not all callbacks unregistered" + + +@pytest.fixture(autouse=True) +def async_get_local_ip_mock() -> Iterable[Mock]: + """Mock the async_get_local_ip utility function to prevent network access.""" + with patch( + "homeassistant.components.dlna_dmr.media_player.async_get_local_ip", + autospec=True, + ) as func: + func.return_value = AddressFamily.AF_INET, LOCAL_IP + yield func diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py new file mode 100644 index 00000000000..1bf93781be1 --- /dev/null +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -0,0 +1,624 @@ +"""Test the DLNA config flow.""" +from __future__ import annotations + +from unittest.mock import Mock + +from async_upnp_client import UpnpDevice, UpnpError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_TYPE, + CONF_URL, +) +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_TYPE, + MOCK_DEVICE_UDN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock and dmr_device_mock fixtures for every test in this module +pytestmark = [ + pytest.mark.usefixtures("domain_data_mock"), + pytest.mark.usefixtures("dmr_device_mock"), +] + +WRONG_DEVICE_TYPE = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + +IMPORTED_DEVICE_NAME = "Imported DMR device" + +MOCK_CONFIG_IMPORT_DATA = { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, +} + +MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" +MOCK_ROOT_DEVICE_TYPE = "ROOT_DEVICE_TYPE" + +MOCK_DISCOVERY = { + 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_ROOT_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, +} + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test user-init'd config flow with user entering a valid URL.""" + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + 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, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_uncontactable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd config flow with user entering an uncontactable URL.""" + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "user" + + +async def test_user_flow_embedded_st( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test user-init'd flow for device with an embedded DMR.""" + # Device is the wrong type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.udn = MOCK_ROOT_DEVICE_UDN + upnp_device.device_type = MOCK_ROOT_DEVICE_TYPE + upnp_device.name = "ROOT_DEVICE_NAME" + embedded_device = Mock(spec=UpnpDevice) + embedded_device.udn = MOCK_DEVICE_UDN + embedded_device.device_type = MOCK_DEVICE_TYPE + embedded_device.name = MOCK_DEVICE_NAME + upnp_device.all_devices.append(embedded_device) + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + 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, + } + assert result["options"] == {CONF_POLL_AVAILABILITY: True} + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test user-init'd config flow with user entering a URL for the wrong device.""" + # Device has a sub device of the right type + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "not_dmr"} + assert result["step_id"] == "user" + + +async def test_import_flow_invalid(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of invalid YAML config.""" + # Missing CONF_URL + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "incomplete_config" + + # Device is not contactable + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device is the wrong type + domain_data_mock.upnp_factory.async_create_device.side_effect = None + upnp_device = domain_data_mock.upnp_factory.async_create_device.return_value + upnp_device.device_type = WRONG_DEVICE_TYPE + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PLATFORM: DLNA_DOMAIN, CONF_URL: MOCK_DEVICE_LOCATION}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + +async def test_import_flow_ssdp_discovered( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device also found via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + 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, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: False, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_direct_connect( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with a device *not found* via SSDP.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + await hass.async_block_till_done() + + assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 + 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, + } + assert result["options"] == { + CONF_LISTEN_PORT: None, + CONF_CALLBACK_URL_OVERRIDE: None, + CONF_POLL_AVAILABILITY: True, + } + entry_id = result["result"].entry_id + + # The config entry should not be duplicated when dlna_dmr is restarted + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG_IMPORT_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Remove the device to clean up all resources, completing its life cycle + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_options( + hass: HomeAssistant, ssdp_scanner_mock: Mock +) -> None: + """Test import of YAML config with options set.""" + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Wait for platform to be fully setup + await hass.async_block_till_done() + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_ssdp( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later found via SSDP.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then discovered via SSDP, import now occurs automatically + ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ + [MOCK_DISCOVERY], + [], + [], + ] + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: False, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_import_flow_deferred_user( + hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock +) -> None: + """Test YAML import of unavailable device later added by user.""" + # Attempted import at hass start fails because device is unavailable + ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: IMPORTED_DEVICE_NAME, + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "could_not_connect" + + # Device becomes available then added by user, use all imported settings + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} + ) + await hass.async_block_till_done() + + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == IMPORTED_DEVICE_NAME + assert result["data"] == { + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_DEVICE_ID: MOCK_DEVICE_UDN, + CONF_TYPE: MOCK_DEVICE_TYPE, + } + assert result["options"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_success(hass: HomeAssistant) -> None: + """Test that SSDP discovery with an available device works.""" + 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["errors"] == {} + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + 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, + } + assert result["options"] == {} + + # Remove the device to clean up all resources, completing its life cycle + entry_id = result["result"].entry_id + assert await hass.config_entries.async_remove(entry_id) == { + "require_restart": False + } + + +async def test_ssdp_flow_unavailable( + hass: HomeAssistant, domain_data_mock: Mock +) -> None: + """Test that SSDP discovery with an unavailable device gives an error message. + + This may occur if the device is turned on, discovered, then turned off + before the user attempts to add it. + """ + 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["errors"] == {} + assert result["step_id"] == "confirm" + + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "could_not_connect"} + assert result["step_id"] == "confirm" + + +async def test_ssdp_flow_existing( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery of existing config entry updates the URL.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_ssdp_flow_upnp_udn( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that SSDP discovery ignores the root device's UDN.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, + ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ssdp.ATTR_UPNP_DEVICE_TYPE: "DIFFERENT_ROOT_DEVICE_TYPE", + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION + + +async def test_options_flow( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test config flow options.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry_mock.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + # Invalid URL for callback (can't be validated automatically by voluptuous) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CALLBACK_URL_OVERRIDE: "Bad url", + CONF_POLL_AVAILABILITY: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_url"} + + # Good data for all fields + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", + CONF_POLL_AVAILABILITY: True, + } diff --git a/tests/components/dlna_dmr/test_data.py b/tests/components/dlna_dmr/test_data.py new file mode 100644 index 00000000000..b4b9fcc76f2 --- /dev/null +++ b/tests/components/dlna_dmr/test_data.py @@ -0,0 +1,121 @@ +"""Tests for the DLNA DMR data module.""" +from __future__ import annotations + +from collections.abc import Iterable +from unittest.mock import ANY, Mock, patch + +from async_upnp_client import UpnpEventHandler +from async_upnp_client.aiohttp import AiohttpNotifyServer +import pytest + +from homeassistant.components.dlna_dmr.const import DOMAIN +from homeassistant.components.dlna_dmr.data import EventListenAddr, get_domain_data +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant + + +@pytest.fixture +def aiohttp_notify_servers_mock() -> Iterable[Mock]: + """Construct mock AiohttpNotifyServer on demand, eliminating network use. + + This fixture provides a list of the constructed servers. + """ + with patch( + "homeassistant.components.dlna_dmr.data.AiohttpNotifyServer" + ) as mock_constructor: + servers = [] + + def make_server(*_args, **_kwargs): + server = Mock(spec=AiohttpNotifyServer) + servers.append(server) + server.event_handler = Mock(spec=UpnpEventHandler) + return server + + mock_constructor.side_effect = make_server + + yield mock_constructor + + # Every server must be stopped if it was started + for server in servers: + assert server.start_server.call_count == server.stop_server.call_count + + +async def test_get_domain_data(hass: HomeAssistant) -> None: + """Test the get_domain_data function returns the same data every time.""" + assert DOMAIN not in hass.data + domain_data = get_domain_data(hass) + assert domain_data is not None + assert get_domain_data(hass) is domain_data + + +async def test_event_notifier( + hass: HomeAssistant, aiohttp_notify_servers_mock: Mock +) -> None: + """Test getting and releasing event notifiers.""" + domain_data = get_domain_data(hass) + + listen_addr = EventListenAddr(None, 0, None) + event_notifier = await domain_data.async_get_event_notifier(listen_addr, hass) + assert event_notifier is not None + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, listen_port=0, listen_host=None, callback_url=None, loop=ANY + ) + + # Same address should give same notifier + listen_addr_2 = EventListenAddr(None, 0, None) + event_notifier_2 = await domain_data.async_get_event_notifier(listen_addr_2, hass) + assert event_notifier_2 is event_notifier + + # Different address should give different notifier + listen_addr_3 = EventListenAddr( + "192.88.99.4", 9999, "http://192.88.99.4:9999/notify" + ) + event_notifier_3 = await domain_data.async_get_event_notifier(listen_addr_3, hass) + assert event_notifier_3 is not None + assert event_notifier_3 is not event_notifier + + # Check that the parameters were passed through to the AiohttpNotifyServer + aiohttp_notify_servers_mock.assert_called_with( + requester=ANY, + listen_port=9999, + listen_host="192.88.99.4", + callback_url="http://192.88.99.4:9999/notify", + loop=ANY, + ) + + # There should be 2 notifiers total, one with 2 references, and a stop callback + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 2, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + # Releasing notifiers should delete them when they have not more references + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr, listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 1, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr) + assert set(domain_data.event_notifiers.keys()) == {listen_addr_3} + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 1} + assert domain_data.stop_listener_remove is not None + + await domain_data.async_release_event_notifier(listen_addr_3) + assert set(domain_data.event_notifiers.keys()) == set() + assert domain_data.event_notifier_refs == {listen_addr: 0, listen_addr_3: 0} + assert domain_data.stop_listener_remove is None + + +async def test_cleanup_event_notifiers(hass: HomeAssistant) -> None: + """Test cleanup function clears all event notifiers.""" + domain_data = get_domain_data(hass) + await domain_data.async_get_event_notifier(EventListenAddr(None, 0, None), hass) + await domain_data.async_get_event_notifier( + EventListenAddr(None, 0, "different"), hass + ) + + await domain_data.async_cleanup_event_notifiers(Event(EVENT_HOMEASSISTANT_STOP)) + + assert not domain_data.event_notifiers + assert not domain_data.event_notifier_refs diff --git a/tests/components/dlna_dmr/test_init.py b/tests/components/dlna_dmr/test_init.py new file mode 100644 index 00000000000..91aec7310ab --- /dev/null +++ b/tests/components/dlna_dmr/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the DLNA DMR __init__ module.""" + +from unittest.mock import Mock + +from async_upnp_client import UpnpError + +from homeassistant.components.dlna_dmr.const import ( + CONF_LISTEN_PORT, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .conftest import MOCK_DEVICE_LOCATION + + +async def test_import_flow_started(hass: HomeAssistant, domain_data_mock: Mock) -> None: + """Test import flow of YAML config is started if there's config data.""" + mock_config: ConfigType = { + MEDIA_PLAYER_DOMAIN: [ + { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + }, + { + CONF_PLATFORM: "other_domain", + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_NAME: "another device", + }, + ] + } + + # Device is not available yet + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Run the setup + await async_setup_component(hass, DLNA_DOMAIN, mock_config) + await hass.async_block_till_done() + + # Check config_flow has completed + assert hass.config_entries.flow.async_progress(include_uninitialized=True) == [] + + # Check device contact attempt was made + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check the device is added to the unmigrated configs + assert domain_data_mock.unmigrated_config == { + MOCK_DEVICE_LOCATION: { + CONF_PLATFORM: DLNA_DOMAIN, + CONF_URL: MOCK_DEVICE_LOCATION, + CONF_LISTEN_PORT: 1234, + } + } diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py new file mode 100644 index 00000000000..99bdc14b553 --- /dev/null +++ b/tests/components/dlna_dmr/test_media_player.py @@ -0,0 +1,1338 @@ +"""Tests for the DLNA DMR media_player module.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable +from datetime import timedelta +from types import MappingProxyType +from unittest.mock import ANY, DEFAULT, Mock, patch + +from async_upnp_client.exceptions import UpnpConnectionError, UpnpError +from async_upnp_client.profiles.dlna import TransportState +import pytest + +from homeassistant import const as ha_const +from homeassistant.components import ssdp +from homeassistant.components.dlna_dmr import media_player +from homeassistant.components.dlna_dmr.const import ( + CONF_CALLBACK_URL_OVERRIDE, + CONF_LISTEN_PORT, + CONF_POLL_AVAILABILITY, + DOMAIN as DLNA_DOMAIN, +) +from homeassistant.components.dlna_dmr.data import EventListenAddr +from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import 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, + async_get as async_get_er, +) +from homeassistant.setup import async_setup_component + +from .conftest import ( + LOCAL_IP, + MOCK_DEVICE_LOCATION, + MOCK_DEVICE_NAME, + MOCK_DEVICE_UDN, + MOCK_DEVICE_USN, + NEW_DEVICE_LOCATION, +) + +from tests.common import MockConfigEntry + +# Auto-use the domain_data_mock fixture for every test in this module +pytestmark = pytest.mark.usefixtures("domain_data_mock") + + +async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: + """Set up a mock DlnaDmrEntity with the given configuration.""" + mock_entry.add_to_hass(hass) + assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + await hass.async_block_till_done() + + entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) + assert len(entries) == 1 + entity_id = entries[0].entity_id + + return entity_id + + +@pytest.fixture +async def mock_entity_id( + hass: HomeAssistant, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a connected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + entity_id = await setup_mock_component(hass, config_entry_mock) + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +@pytest.fixture +async def mock_disconnected_entity_id( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> AsyncIterable[str]: + """Fixture to set up a mock DlnaDmrEntity in a disconnected state. + + Yields the entity ID. Cleans up the entity after the test is complete. + """ + # Cause the connection attempt to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + entity_id = await setup_mock_component(hass, config_entry_mock) + + yield entity_id + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_setup_entry_no_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test async_setup_entry creates a DlnaDmrEntity when no options are set. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType({}) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, but does not ping, + # because poll_availability is False + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + # 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 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_setup_entry_with_options( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test setting options leads to a DlnaDmrEntity with custom event_handler. + + Check that the device is constructed properly as part of the test. + """ + config_entry_mock.options = MappingProxyType( + { + CONF_LISTEN_PORT: 2222, + CONF_CALLBACK_URL_OVERRIDE: "http://192.88.99.10/events", + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # 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 + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has a connected DmrDevice + assert mock_state.state == media_player.STATE_IDLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update retrieves state from the device, and also pings it, + # because poll_availability is True + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # 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 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_event_subscribe_failure( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test _device_connect aborts when async_subscribe_services fails.""" + dmr_device_mock.async_subscribe_services.side_effect = UpnpError + + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Device should not be connected + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Device should not be unsubscribed + dmr_device_mock.async_unsubscribe_services.assert_not_awaited() + + # Clear mocks for tear down checks + dmr_device_mock.async_subscribe_services.reset_mock() + + # Unload config entry to clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_available_device( + hass: HomeAssistant, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test a DlnaDmrEntity with a connected DmrDevice.""" + # Check hass device information is filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + # Device properties are set in dmr_device_mock before the entity gets constructed + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # Check entity state gets updated when device changes state + for (dev_state, ent_state) in [ + (None, ha_const.STATE_ON), + (TransportState.STOPPED, ha_const.STATE_IDLE), + (TransportState.PLAYING, ha_const.STATE_PLAYING), + (TransportState.TRANSITIONING, ha_const.STATE_PLAYING), + (TransportState.PAUSED_PLAYBACK, ha_const.STATE_PAUSED), + (TransportState.PAUSED_RECORDING, ha_const.STATE_PAUSED), + (TransportState.RECORDING, ha_const.STATE_IDLE), + (TransportState.NO_MEDIA_PRESENT, ha_const.STATE_IDLE), + (TransportState.VENDOR_DEFINED, ha_const.STATE_UNKNOWN), + ]: + dmr_device_mock.profile_device.available = True + dmr_device_mock.transport_state = dev_state + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ent_state + + dmr_device_mock.profile_device.available = False + dmr_device_mock.transport_state = TransportState.PLAYING + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.state == ha_const.STATE_UNAVAILABLE + + dmr_device_mock.profile_device.available = True + await async_update_entity(hass, mock_entity_id) + + # Check attributes come directly from the device + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + attrs = entity_state.attributes + assert attrs is not None + + assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level + assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted + assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration + assert attrs[mp_const.ATTR_MEDIA_POSITION] is dmr_device_mock.media_position + assert ( + attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] + is dmr_device_mock.media_position_updated_at + ) + assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # Entity picture is cached, won't correspond to remote image + assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + + # Check supported feature flags, one at a time. + # tuple(async_upnp_client feature check property, HA feature flag) + FEATURE_FLAGS: list[tuple[str, int]] = [ + ("has_volume_level", mp_const.SUPPORT_VOLUME_SET), + ("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE), + ("can_play", mp_const.SUPPORT_PLAY), + ("can_pause", mp_const.SUPPORT_PAUSE), + ("can_stop", mp_const.SUPPORT_STOP), + ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), + ("can_next", mp_const.SUPPORT_NEXT_TRACK), + ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), + ("can_seek_rel_time", mp_const.SUPPORT_SEEK), + ] + # Clear all feature properties + for feat_prop, _ in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, False) + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + # Test the properties cumulatively + expected_features = 0 + for feat_prop, flag in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, True) + expected_features |= flag + await async_update_entity(hass, mock_entity_id) + entity_state = hass.states.get(mock_entity_id) + assert entity_state is not None + assert ( + entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] + == expected_features + ) + + # Check interface methods interact directly with the device + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + dmr_device_mock.async_set_volume_level.assert_awaited_once_with(0.80) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + dmr_device_mock.async_mute_volume.assert_awaited_once_with(True) + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_pause.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_next.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: mock_entity_id}, + blocking=True, + ) + dmr_device_mock.async_previous.assert_awaited_once_with() + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SEEK_POSITION: 33}, + blocking=True, + ) + dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33)) + + # play_media performs a few calls to the device for setup and play + # Start from stopped, and device can stop too + dmr_device_mock.can_stop = True + dmr_device_mock.transport_state = TransportState.STOPPED + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + 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" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_awaited_once_with() + + # play_media again, while the device is already playing and can't stop + dmr_device_mock.can_stop = False + dmr_device_mock.transport_state = TransportState.PLAYING + dmr_device_mock.async_stop.reset_mock() + dmr_device_mock.async_set_transport_uri.reset_mock() + dmr_device_mock.async_wait_for_can_play.reset_mock() + dmr_device_mock.async_play.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + blocking=True, + ) + 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" + ) + dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_play.assert_not_awaited() + + +async def test_unavailable_device( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, +) -> None: + """Test a DlnaDmrEntity with out a connected DmrDevice.""" + # Cause connection attempts to fail + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + + with patch( + "homeassistant.components.dlna_dmr.media_player.DmrDevice", autospec=True + ) as dmr_device_constructor_mock: + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + + # Check device is not created + dmr_device_constructor_mock.assert_not_called() + + # Check attempt was made to create a device from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + # Check event notifiers are not acquired + domain_data_mock.async_get_event_notifier.assert_not_called() + # Check SSDP notifications are registered + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"USN": MOCK_DEVICE_USN} + ) + ssdp_scanner_mock.async_register_callback.assert_any_call( + ANY, {"_udn": MOCK_DEVICE_UDN, "NTS": "ssdp:byebye"} + ) + # Quick check of the state to verify the entity has no connected DmrDevice + assert mock_state.state == ha_const.STATE_UNAVAILABLE + # Check the name matches that supplied + assert mock_state.name == MOCK_DEVICE_NAME + + # Check that an update does not attempt to contact the device because + # poll_availability is False + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_not_called() + + # Now set poll_availability = True and expect construction attempt + hass.config_entries.async_update_entry( + config_entry_mock, options={CONF_POLL_AVAILABILITY: True} + ) + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check attributes are unavailable + attrs = mock_state.attributes + for attr in ATTR_TO_PROPERTY: + assert attr not in attrs + + assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + + # Check service calls do nothing + SERVICES: list[tuple[str, dict]] = [ + (ha_const.SERVICE_VOLUME_SET, {mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}), + (ha_const.SERVICE_VOLUME_MUTE, {mp_const.ATTR_MEDIA_VOLUME_MUTED: True}), + (ha_const.SERVICE_MEDIA_PAUSE, {}), + (ha_const.SERVICE_MEDIA_PLAY, {}), + (ha_const.SERVICE_MEDIA_STOP, {}), + (ha_const.SERVICE_MEDIA_NEXT_TRACK, {}), + (ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, {}), + (ha_const.SERVICE_MEDIA_SEEK, {mp_const.ATTR_MEDIA_SEEK_POSITION: 33}), + ( + mp_const.SERVICE_PLAY_MEDIA, + { + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + }, + ), + ] + for service, data in SERVICES: + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: mock_entity_id, **data}, + blocking=True, + ) + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # 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 + + # Check event notifiers are not released + domain_data_mock.async_release_event_notifier.assert_not_called() + + # Confirm the entity is still unavailable + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_become_available( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming available after the entity is constructed.""" + # Cause connection attempts to fail before adding entity + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check hass device information has not been filled in yet + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is None + + # Mock device is now available. + domain_data_mock.upnp_factory.async_create_device.side_effect = None + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Send an SSDP notification from the now alive device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device was created from the supplied URL + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + # Check event notifiers are acquired + domain_data_mock.async_get_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None), hass + ) + # Check UPnP services are subscribed + dmr_device_mock.async_subscribe_services.assert_awaited_once_with( + auto_resubscribe=True + ) + assert dmr_device_mock.on_event is not None + # Quick check of the state to verify the entity has a connected DmrDevice + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + # Check hass device information is now filled in + dev_reg = async_get_dr(hass) + device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + assert device is not None + assert device.manufacturer == "device_manufacturer" + assert device.model == "device_model_name" + assert device.name == "device_name" + + # 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 + + # Confirm the entity has disconnected from the device + domain_data_mock.async_release_event_notifier.assert_awaited_once() + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + assert dmr_device_mock.on_event is None + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_alive_but_gone( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, +) -> None: + """Test a device sending an SSDP alive announcement, but not being connectable.""" + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + # Send an SSDP notification from the still missing device + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Device should still be unavailable + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_multiple_ssdp_alive( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test multiple SSDP alive notifications is ok, only connects to device once.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Contacting the device takes long enough that 2 simultaneous attempts could be made + async def create_device_delayed(_location): + """Delay before continuing with async_create_device. + + This gives a chance for parallel calls to `_device_connect` to occur. + """ + await asyncio.sleep(0.1) + return DEFAULT + + domain_data_mock.upnp_factory.async_create_device.side_effect = ( + create_device_delayed + ) + + # Send two SSDP notifications with the new device URL + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Check device is contacted exactly once + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + NEW_DEVICE_LOCATION + ) + + # Device should be available + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + +async def test_ssdp_byebye( + hass: HomeAssistant, + ssdp_scanner_mock: Mock, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device is disconnected when byebye is received.""" + # First byebye will cause a disconnect + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + # Device should be gone + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == media_player.STATE_IDLE + + # Second byebye will do nothing + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:byebye", + }, + ssdp.SsdpChange.BYEBYE, + ) + + dmr_device_mock.async_unsubscribe_services.assert_awaited_once() + + +async def test_ssdp_update_seen_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device does not reconnect when it gets ssdp:update with next bootid.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device was not reconnected, even with a new boot ID + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with same next boot ID, again + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send SSDP update with bad next boot ID + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Nothing should change + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should not reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + +async def test_ssdp_update_missed_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test device disconnects when it gets ssdp:update bootid it wasn't expecting.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Send SSDP update with skipped boot ID (not previously seen) + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + "_udn": MOCK_DEVICE_UDN, + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "3", + }, + ssdp.SsdpChange.UPDATE, + ) + await hass.async_block_till_done() + + # Device should not reconnect yet + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Send a new SSDP alive with the new boot ID, device should reconnect + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "3", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + +async def test_ssdp_bootid( + hass: HomeAssistant, + domain_data_mock: Mock, + ssdp_scanner_mock: Mock, + mock_disconnected_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test an alive with a new BOOTID.UPNP.ORG header causes a reconnect.""" + # Start with a disconnected device + entity_id = mock_disconnected_entity_id + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Send SSDP alive with boot ID + ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send SSDP alive with same boot ID, nothing should happen + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "1", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 1 + assert dmr_device_mock.async_unsubscribe_services.call_count == 0 + + # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected + await ssdp_callback( + { + ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, + ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, + ssdp.ATTR_SSDP_BOOTID: "2", + }, + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + mock_state = hass.states.get(entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + assert dmr_device_mock.async_subscribe_services.call_count == 2 + assert dmr_device_mock.async_unsubscribe_services.call_count == 1 + + +async def test_become_unavailable( + hass: HomeAssistant, + domain_data_mock: Mock, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test a device becoming unavailable.""" + # Check async_update currently works + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + + # Now break the network connection and try to contact the device + dmr_device_mock.async_set_volume_level.side_effect = UpnpConnectionError + dmr_device_mock.async_update.reset_mock() + + # Interface service calls should flag that the device is unavailable, but + # not disconnect it immediately + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # With a working connection, the state should be restored + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_any_call(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Break the service again, and the connection too. An update will cause the + # device to be disconnected + dmr_device_mock.async_update.reset_mock() + dmr_device_mock.async_update.side_effect = UpnpConnectionError + + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + blocking=True, + ) + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, +) -> None: + """Test device becomes available and noticed via poll_availability.""" + # Start with a disconnected device and poll_availability=True + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError + config_entry_mock.options = MappingProxyType( + { + CONF_POLL_AVAILABILITY: True, + } + ) + mock_entity_id = await setup_mock_component(hass, config_entry_mock) + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Check that an update will poll the device for availability + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # "Reconnect" the device + domain_data_mock.upnp_factory.async_create_device.side_effect = None + + # Check that an update will notice the device and connect to it + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await async_update_entity(hass, mock_entity_id) + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + # Clean up + assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == { + "require_restart": False + } + + +async def test_disappearing_device( + hass: HomeAssistant, + mock_disconnected_entity_id: str, +) -> None: + """Test attribute update or service call as device disappears. + + Normally HA will check if the entity is available before updating attributes + or calling a service, but it's possible for the device to go offline in + between the check and the method call. Here we test by accessing the entity + directly to skip the availability check. + """ + # Retrieve entity directly. + entity: media_player.DlnaDmrEntity = hass.data[MP_DOMAIN].get_entity( + mock_disconnected_entity_id + ) + + # Test attribute access + for attr in ATTR_TO_PROPERTY: + value = getattr(entity, attr) + assert value is None + + # media_image_url is normally hidden by entity_picture, but we want a direct check + assert entity.media_image_url is None + + # Test service calls + await entity.async_set_volume_level(0.1) + await entity.async_mute_volume(True) + await entity.async_media_pause() + await entity.async_media_play() + await entity.async_media_stop() + await entity.async_media_seek(22.0) + await entity.async_play_media("", "") + await entity.async_media_previous_track() + await entity.async_media_next_track() + + +async def test_resubscribe_failure( + hass: HomeAssistant, + mock_entity_id: str, + dmr_device_mock: Mock, +) -> None: + """Test failure to resubscribe to events notifications causes an update ping.""" + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=False) + dmr_device_mock.async_update.reset_mock() + + on_event = dmr_device_mock.on_event + on_event(None, []) + await hass.async_block_till_done() + + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_called_with(do_ping=True) + + +async def test_config_update_listen_port( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_LISTEN_PORT.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed port will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 1234, None), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_connect_failure( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gracefully handles connect failure after config change.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_LISTEN_PORT: 1234, + }, + ) + await hass.async_block_till_done() + + # Old event listener was released, new event listener was not created + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # There was an attempt to connect to the device + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + + # Check that its no longer connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + +async def test_config_update_callback_url( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_CALLBACK_URL_OVERRIDE.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_CALLBACK_URL_OVERRIDE: "http://www.example.net/notify", + }, + ) + await hass.async_block_till_done() + + # A new event listener with the changed callback URL will be used + domain_data_mock.async_release_event_notifier.assert_awaited_once_with( + EventListenAddr(LOCAL_IP, 0, None) + ) + domain_data_mock.async_get_event_notifier.assert_awaited_with( + EventListenAddr(LOCAL_IP, 0, "http://www.example.net/notify"), hass + ) + + # Device will be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_awaited_once_with( + MOCK_DEVICE_LOCATION + ) + assert dmr_device_mock.async_unsubscribe_services.await_count == 1 + assert dmr_device_mock.async_subscribe_services.await_count == 2 + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE + + +async def test_config_update_poll_availability( + hass: HomeAssistant, + domain_data_mock: Mock, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test DlnaDmrEntity gets updated by ConfigEntry's CONF_POLL_AVAILABILITY.""" + domain_data_mock.upnp_factory.async_create_device.reset_mock() + + # Updates of the device will not ping it yet + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=False) + + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_POLL_AVAILABILITY: True, + }, + ) + await hass.async_block_till_done() + + # Event listeners will not change + domain_data_mock.async_release_event_notifier.assert_not_awaited() + domain_data_mock.async_get_event_notifier.assert_awaited_once() + + # Device will not be reconnected + domain_data_mock.upnp_factory.async_create_device.assert_not_awaited() + assert dmr_device_mock.async_unsubscribe_services.await_count == 0 + assert dmr_device_mock.async_subscribe_services.await_count == 1 + + # Updates of the device will now ping it + await async_update_entity(hass, mock_entity_id) + dmr_device_mock.async_update.assert_awaited_with(do_ping=True) + + # Check that its still connected + mock_state = hass.states.get(mock_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_IDLE diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 86a9b3eea21..f3ddab39c39 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -69,7 +69,7 @@ async def test_ssdp_flow_dispatched_on_st(mock_get_ssdp, hass, caplog, mock_flow ssdp.ATTR_SSDP_SERVER: "mock-server", ssdp.ATTR_SSDP_EXT: "", ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } assert "Failed to fetch ssdp data" not in caplog.text @@ -411,7 +411,7 @@ async def test_scan_with_registered_callback( ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "x-rincon-bootseq": "55", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, }, ssdp.SsdpChange.ALIVE, @@ -465,7 +465,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } ] @@ -482,7 +482,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } ] @@ -498,7 +498,7 @@ async def test_getting_existing_headers(mock_get_ssdp, hass, aioclient_mock): ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - "_udn": ANY, + ssdp.ATTR_SSDP_UDN: ANY, "_timestamp": ANY, } diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 58e4c6a2275..077ed292d10 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs from aiohttp import ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError from aiohttp.streams import StreamReader +from multidict import CIMultiDict from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE @@ -179,7 +180,7 @@ class AiohttpClientMockResponse: self.response = response self.exc = exc self.side_effect = side_effect - self._headers = headers or {} + self._headers = CIMultiDict(headers or {}) self._cookies = {} if cookies: