Improve dlna_dmr code quality (#56886)
* Listen for config updates from DlnaDmrEntity.async_added_to_hass
Use `Entity.async_on_remove` for dealing with callback cancellation,
instead of re-inventing the wheel with `_remove_ssdp_callbacks`.
* Use async_write_ha_state within async methods
* Import YAML config from async_setup_platform
* Import flow prompts user when device is uncontactable during migration
When config flow is able to contact a device, or when it has information
from SSDP, it will create config entries without error. If the device is
uncontactable at this point then it will appear as unavailable in HA
until it is turned on again.
When import flow cannot migrate an entry because it needs to contact the
device and can't, it will notify the user with a config flow form.
* Don't del unused parameters, HA pylint doesn't care
* Remove unused imports from tests
* Abort config flow at earliest opportunity
* Return async_abort instead of raising AbortFlow
* Consolidate config entry test cleanup into a single function
* fixup! Consolidate config entry test cleanup into a single function
Revert "Consolidate config entry test cleanup into a single function"
This reverts commit 8220da7263
.
* Check resource acquisition/release in specific tests
* fixup! Check resource acquisition/release in specific tests
* Remove unused network dependency from manifest
* _on_event runs in async context
* Call async_write_ha_state directly (not via shedule_update)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
b980dc7e33
commit
667e730946
11 changed files with 436 additions and 436 deletions
|
@ -3,39 +3,13 @@ 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
|
||||
from .const import 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:
|
||||
|
|
|
@ -25,6 +25,7 @@ from .const import (
|
|||
CONF_CALLBACK_URL_OVERRIDE,
|
||||
CONF_LISTEN_PORT,
|
||||
CONF_POLL_AVAILABILITY,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .data import get_domain_data
|
||||
|
@ -51,6 +52,11 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
self._discoveries: list[Mapping[str, str]] = []
|
||||
self._location: str | None = None
|
||||
self._udn: str | None = None
|
||||
self._device_type: str | None = None
|
||||
self._name: str | None = None
|
||||
self._options: dict[str, Any] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
|
@ -68,22 +74,18 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
"""
|
||||
LOGGER.debug("async_step_user: user_input: %s", user_input)
|
||||
|
||||
# Device setup manually, assume we don't get SSDP broadcast notifications
|
||||
self._options[CONF_POLL_AVAILABILITY] = True
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._location = user_input[CONF_URL]
|
||||
try:
|
||||
discovery = await self._async_connect(user_input[CONF_URL])
|
||||
await self._async_connect()
|
||||
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)
|
||||
return self._create_entry()
|
||||
|
||||
data_schema = vol.Schema({CONF_URL: str})
|
||||
return self.async_show_form(
|
||||
|
@ -93,9 +95,10 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
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.
|
||||
This flow is triggered by `async_setup_platform`. If the device has not
|
||||
been migrated, and can be connected to, automatically import it. If it
|
||||
cannot be connected to, prompt the user to turn it on. If it has already
|
||||
been migrated, do nothing.
|
||||
"""
|
||||
LOGGER.debug("async_step_import: import_data: %s", import_data)
|
||||
|
||||
|
@ -103,123 +106,183 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
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]})
|
||||
self._location = import_data[CONF_URL]
|
||||
self._async_abort_entries_match({CONF_URL: self._location})
|
||||
|
||||
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])
|
||||
# Use the location as this config flow's unique ID until UDN is known
|
||||
await self.async_set_unique_id(self._location)
|
||||
|
||||
# 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,
|
||||
}
|
||||
self._options[CONF_LISTEN_PORT] = import_data.get(CONF_LISTEN_PORT)
|
||||
self._options[CONF_CALLBACK_URL_OVERRIDE] = import_data.get(
|
||||
CONF_CALLBACK_URL_OVERRIDE
|
||||
)
|
||||
|
||||
# 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]
|
||||
self._name = import_data.get(CONF_NAME)
|
||||
|
||||
LOGGER.debug("Entry %s ready for import", import_data[CONF_URL])
|
||||
return await self._async_create_entry_from_discovery(discovery, options)
|
||||
discoveries = await self._async_get_discoveries()
|
||||
|
||||
# Find the device in the list of unconfigured devices
|
||||
for discovery in discoveries:
|
||||
if discovery[ssdp.ATTR_SSDP_LOCATION] == self._location:
|
||||
# Device found via SSDP, it shouldn't need polling
|
||||
self._options[CONF_POLL_AVAILABILITY] = False
|
||||
# Discovery info has everything required to create config entry
|
||||
await self._async_set_info_from_discovery(discovery)
|
||||
LOGGER.debug(
|
||||
"Entry %s found via SSDP, with UDN %s",
|
||||
self._location,
|
||||
self._udn,
|
||||
)
|
||||
return self._create_entry()
|
||||
|
||||
# This device will need to be polled
|
||||
self._options[CONF_POLL_AVAILABILITY] = True
|
||||
|
||||
# Device was not found via SSDP, connect directly for configuration
|
||||
try:
|
||||
await self._async_connect()
|
||||
except ConnectError as err:
|
||||
# This will require user action
|
||||
LOGGER.debug("Entry %s not imported yet: %s", self._location, err.args[0])
|
||||
return await self.async_step_import_turn_on()
|
||||
|
||||
LOGGER.debug("Entry %s ready for import", self._location)
|
||||
return self._create_entry()
|
||||
|
||||
async def async_step_import_turn_on(
|
||||
self, user_input: FlowInput = None
|
||||
) -> FlowResult:
|
||||
"""Request the user to turn on the device so that import can finish."""
|
||||
LOGGER.debug("async_step_import_turn_on: %s", user_input)
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._name or self._location}
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._async_connect()
|
||||
except ConnectError as err:
|
||||
errors["base"] = err.args[0]
|
||||
else:
|
||||
return self._create_entry()
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(step_id="import_turn_on", errors=errors)
|
||||
|
||||
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]
|
||||
await self._async_set_info_from_discovery(discovery_info)
|
||||
|
||||
udn = discovery_info[ssdp.ATTR_SSDP_UDN]
|
||||
location = discovery_info[ssdp.ATTR_SSDP_LOCATION]
|
||||
# Abort if a migration flow for the device's location is in progress
|
||||
for progress in self._async_in_progress(include_uninitialized=True):
|
||||
if progress["context"].get("unique_id") == self._location:
|
||||
LOGGER.debug(
|
||||
"Aborting SSDP setup because migration for %s is in progress",
|
||||
self._location,
|
||||
)
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
|
||||
# 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}
|
||||
self.context["title_placeholders"] = {"name": self._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.
|
||||
"""
|
||||
"""Allow the user to confirm adding the device."""
|
||||
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)
|
||||
return self._create_entry()
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(step_id="confirm", errors=errors)
|
||||
return self.async_show_form(step_id="confirm")
|
||||
|
||||
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)
|
||||
async def _async_connect(self) -> None:
|
||||
"""Connect to a device to confirm it works and gather extra information.
|
||||
|
||||
location = discovery[ssdp.ATTR_SSDP_LOCATION]
|
||||
udn = discovery[ssdp.ATTR_SSDP_UDN]
|
||||
Updates this flow's unique ID to the device UDN if not already done.
|
||||
Raises ConnectError if something goes wrong.
|
||||
"""
|
||||
LOGGER.debug("_async_connect: location: %s", self._location)
|
||||
assert self._location, "self._location has not been set before connect"
|
||||
|
||||
domain_data = get_domain_data(self.hass)
|
||||
try:
|
||||
device = await domain_data.upnp_factory.async_create_device(self._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
|
||||
|
||||
if not self._udn:
|
||||
self._udn = device.udn
|
||||
await self.async_set_unique_id(self._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})
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: self._location}, reload_on_update=False
|
||||
)
|
||||
|
||||
parsed_url = urlparse(location)
|
||||
title = discovery.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or parsed_url.hostname
|
||||
if not self._device_type:
|
||||
self._device_type = device.device_type
|
||||
|
||||
if not self._name:
|
||||
self._name = device.name
|
||||
|
||||
def _create_entry(self) -> FlowResult:
|
||||
"""Create a config entry, assuming all required information is now known."""
|
||||
LOGGER.debug(
|
||||
"_async_create_entry: location: %s, UDN: %s", self._location, self._udn
|
||||
)
|
||||
assert self._location
|
||||
assert self._udn
|
||||
assert self._device_type
|
||||
|
||||
title = self._name or urlparse(self._location).hostname or DEFAULT_NAME
|
||||
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],
|
||||
CONF_URL: self._location,
|
||||
CONF_DEVICE_ID: self._udn,
|
||||
CONF_TYPE: self._device_type,
|
||||
}
|
||||
return self.async_create_entry(title=title, data=data, options=options)
|
||||
return self.async_create_entry(title=title, data=data, options=self._options)
|
||||
|
||||
async def _async_set_info_from_discovery(
|
||||
self, discovery_info: Mapping[str, Any], abort_if_configured: bool = True
|
||||
) -> None:
|
||||
"""Set information required for a config entry from the SSDP discovery."""
|
||||
LOGGER.debug(
|
||||
"_async_set_info_from_discovery: location: %s, UDN: %s",
|
||||
discovery_info[ssdp.ATTR_SSDP_LOCATION],
|
||||
discovery_info[ssdp.ATTR_SSDP_UDN],
|
||||
)
|
||||
|
||||
if not self._location:
|
||||
self._location = discovery_info[ssdp.ATTR_SSDP_LOCATION]
|
||||
assert isinstance(self._location, str)
|
||||
|
||||
self._udn = discovery_info[ssdp.ATTR_SSDP_UDN]
|
||||
await self.async_set_unique_id(self._udn)
|
||||
|
||||
if abort_if_configured:
|
||||
# Abort if already configured, but update the last-known location
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: self._location}, reload_on_update=False
|
||||
)
|
||||
|
||||
self._device_type = (
|
||||
discovery_info.get(ssdp.ATTR_SSDP_NT) or discovery_info[ssdp.ATTR_SSDP_ST]
|
||||
)
|
||||
self._name = (
|
||||
discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
|
||||
or urlparse(self._location).hostname
|
||||
or DEFAULT_NAME
|
||||
)
|
||||
|
||||
async def _async_get_discoveries(self) -> list[Mapping[str, str]]:
|
||||
"""Get list of unconfigured DLNA devices discovered by SSDP."""
|
||||
|
@ -245,32 +308,6 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
|
||||
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.
|
||||
|
|
|
@ -3,8 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, NamedTuple, cast
|
||||
from typing import NamedTuple, cast
|
||||
|
||||
from async_upnp_client import UpnpEventHandler, UpnpFactory, UpnpRequester
|
||||
from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
|
||||
|
@ -33,7 +32,6 @@ class DlnaDmrData:
|
|||
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."""
|
||||
|
@ -43,11 +41,9 @@ class DlnaDmrData:
|
|||
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())
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"requirements": ["async-upnp-client==0.22.5"],
|
||||
"dependencies": ["network", "ssdp"],
|
||||
"dependencies": ["ssdp"],
|
||||
"codeowners": ["@StevenLooman", "@chishm"],
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
|
|
|
@ -42,12 +42,12 @@ from homeassistant.core import HomeAssistant
|
|||
from homeassistant.helpers import device_registry, entity_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
CONF_CALLBACK_URL_OVERRIDE,
|
||||
CONF_LISTEN_PORT,
|
||||
CONF_POLL_AVAILABILITY,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
LOGGER as _LOGGER,
|
||||
MEDIA_TYPE_MAP,
|
||||
|
@ -69,7 +69,7 @@ PLATFORM_SCHEMA = vol.All(
|
|||
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_NAME): cv.string,
|
||||
vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url,
|
||||
}
|
||||
),
|
||||
|
@ -98,13 +98,35 @@ def catch_request_errors(func: Func) -> Func:
|
|||
return cast(Func, wrapper)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up DLNA media_player platform."""
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.warning(
|
||||
"Configuring dlna_dmr via yaml is deprecated; the configuration for"
|
||||
" %s will be migrated to a config entry and can be safely removed when"
|
||||
"migration is complete",
|
||||
config.get(CONF_URL),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
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)
|
||||
|
||||
# Create our own device-wrapping entity
|
||||
|
@ -118,10 +140,6 @@ async def async_setup_entry(
|
|||
location=entry.data[CONF_URL],
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
entry.add_update_listener(entity.async_config_update_listener)
|
||||
)
|
||||
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
|
@ -139,7 +157,6 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
|
||||
_device_lock: asyncio.Lock # Held when connecting or disconnecting the device
|
||||
_device: DmrDevice | None = None
|
||||
_remove_ssdp_callbacks: list[Callable]
|
||||
check_available: bool = False
|
||||
|
||||
# Track BOOTID in SSDP advertisements for device changes
|
||||
|
@ -167,10 +184,19 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
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."""
|
||||
# Update this entity when the associated config entry is modified
|
||||
if self.registry_entry and self.registry_entry.config_entry_id:
|
||||
config_entry = self.hass.config_entries.async_get_entry(
|
||||
self.registry_entry.config_entry_id
|
||||
)
|
||||
assert config_entry is not None
|
||||
self.async_on_remove(
|
||||
config_entry.add_update_listener(self.async_config_update_listener)
|
||||
)
|
||||
|
||||
# Try to connect to the last known location, but don't worry if not available
|
||||
if not self._device:
|
||||
try:
|
||||
|
@ -179,7 +205,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
_LOGGER.debug("Couldn't connect immediately: %r", err)
|
||||
|
||||
# Get SSDP notifications for only this device
|
||||
self._remove_ssdp_callbacks.append(
|
||||
self.async_on_remove(
|
||||
await ssdp.async_register_callback(
|
||||
self.hass, self.async_ssdp_callback, {"USN": self.usn}
|
||||
)
|
||||
|
@ -189,7 +215,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
# (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(
|
||||
self.async_on_remove(
|
||||
await ssdp.async_register_callback(
|
||||
self.hass,
|
||||
self.async_ssdp_callback,
|
||||
|
@ -199,9 +225,6 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
|
||||
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()
|
||||
|
||||
async def async_ssdp_callback(
|
||||
|
@ -255,13 +278,12 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
)
|
||||
|
||||
# Device could have been de/re-connected, state probably changed
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_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,
|
||||
|
@ -292,7 +314,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
_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()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _device_connect(self, location: str) -> None:
|
||||
"""Connect to the device now that it's available."""
|
||||
|
@ -415,11 +437,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
|||
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()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
"url": "[%key:common::config_flow::data::url%]"
|
||||
}
|
||||
},
|
||||
"import_turn_on": {
|
||||
"description": "Please turn on the device and click submit to continue migration"
|
||||
},
|
||||
"confirm": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
}
|
||||
|
|
|
@ -17,6 +17,9 @@
|
|||
"confirm": {
|
||||
"description": "Do you want to start set up?"
|
||||
},
|
||||
"import_turn_on": {
|
||||
"description": "Please turn on the device and click confirm to continue migration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "URL"
|
||||
|
|
|
@ -52,20 +52,12 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]:
|
|||
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]:
|
||||
def config_entry_mock() -> MockConfigEntry:
|
||||
"""Mock a config entry for this platform."""
|
||||
mock_entry = MockConfigEntry(
|
||||
unique_id=MOCK_DEVICE_UDN,
|
||||
|
@ -78,7 +70,7 @@ def config_entry_mock() -> Iterable[MockConfigEntry]:
|
|||
title=MOCK_DEVICE_NAME,
|
||||
options={},
|
||||
)
|
||||
yield mock_entry
|
||||
return mock_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
@ -100,14 +92,6 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]:
|
|||
|
||||
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]:
|
||||
|
@ -125,9 +109,6 @@ def ssdp_scanner_mock() -> Iterable[Mock]:
|
|||
reg_callback = mock_scanner.return_value.async_register_callback
|
||||
reg_callback.return_value = Mock(return_value=None)
|
||||
yield mock_scanner.return_value
|
||||
assert (
|
||||
reg_callback.call_count == reg_callback.return_value.call_count
|
||||
), "Not all callbacks unregistered"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
@ -86,12 +86,6 @@ async def test_user_flow(hass: HomeAssistant) -> None:
|
|||
# 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
|
||||
|
@ -154,12 +148,6 @@ async def test_user_flow_embedded_st(
|
|||
# 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."""
|
||||
|
@ -194,30 +182,6 @@ async def test_import_flow_invalid(hass: HomeAssistant, domain_data_mock: Mock)
|
|||
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
|
||||
|
@ -248,7 +212,6 @@ async def test_import_flow_ssdp_discovered(
|
|||
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 = [
|
||||
|
@ -267,11 +230,6 @@ async def test_import_flow_ssdp_discovered(
|
|||
# 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
|
||||
|
@ -299,7 +257,6 @@ async def test_import_flow_direct_connect(
|
|||
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(
|
||||
|
@ -310,10 +267,78 @@ async def test_import_flow_direct_connect(
|
|||
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_offline(
|
||||
hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock
|
||||
) -> None:
|
||||
"""Test import flow of offline device."""
|
||||
# Device is not yet 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,
|
||||
CONF_NAME: IMPORTED_DEVICE_NAME,
|
||||
CONF_CALLBACK_URL_OVERRIDE: "http://override/callback",
|
||||
CONF_LISTEN_PORT: 2222,
|
||||
},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "import_turn_on"
|
||||
|
||||
import_flow_id = result["flow_id"]
|
||||
|
||||
# User clicks submit, same form is displayed with an error
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
import_flow_id, user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": "could_not_connect"}
|
||||
assert result["step_id"] == "import_turn_on"
|
||||
|
||||
# Device is discovered via SSDP, new flow should not be initialized
|
||||
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,
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
# User clicks submit, config entry should be created
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
import_flow_id, user_input={}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
# Options should be retained
|
||||
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()
|
||||
|
||||
|
||||
async def test_import_flow_options(
|
||||
|
@ -351,134 +376,6 @@ async def test_import_flow_options(
|
|||
# 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."""
|
||||
|
@ -488,7 +385,6 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None:
|
|||
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(
|
||||
|
@ -505,20 +401,14 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None:
|
|||
}
|
||||
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.
|
||||
"""Test that SSDP discovery with an unavailable device still succeeds.
|
||||
|
||||
This may occur if the device is turned on, discovered, then turned off
|
||||
before the user attempts to add it.
|
||||
All the required information for configuration is obtained from the SSDP
|
||||
message, there's no need to connect to the device to configure it.
|
||||
"""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DLNA_DOMAIN,
|
||||
|
@ -526,7 +416,6 @@ async def test_ssdp_flow_unavailable(
|
|||
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
|
||||
|
@ -534,9 +423,16 @@ async def test_ssdp_flow_unavailable(
|
|||
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"
|
||||
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"] == {}
|
||||
|
||||
|
||||
async def test_ssdp_flow_existing(
|
||||
|
|
|
@ -1,59 +1,60 @@
|
|||
"""Tests for the DLNA DMR __init__ module."""
|
||||
"""Test the DLNA DMR component setup and cleanup."""
|
||||
|
||||
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.components import media_player
|
||||
from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import MOCK_DEVICE_LOCATION
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
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)
|
||||
async def test_resource_lifecycle(
|
||||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
config_entry_mock: MockConfigEntry,
|
||||
ssdp_scanner_mock: Mock,
|
||||
dmr_device_mock: Mock,
|
||||
) -> None:
|
||||
"""Test that resources are acquired/released as the entity is setup/unloaded."""
|
||||
# Set up the config entry
|
||||
config_entry_mock.add_to_hass(hass)
|
||||
assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True
|
||||
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 entity is created and working
|
||||
entries = entity_registry.async_entries_for_config_entry(
|
||||
entity_registry.async_get(hass), config_entry_mock.entry_id
|
||||
)
|
||||
assert len(entries) == 1
|
||||
entity_id = entries[0].entity_id
|
||||
mock_state = hass.states.get(entity_id)
|
||||
assert mock_state is not None
|
||||
assert mock_state.state == media_player.STATE_IDLE
|
||||
|
||||
# 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,
|
||||
}
|
||||
# Check update listeners and event notifiers are subscribed
|
||||
assert len(config_entry_mock.update_listeners) == 1
|
||||
assert domain_data_mock.async_get_event_notifier.await_count == 1
|
||||
assert domain_data_mock.async_release_event_notifier.await_count == 0
|
||||
assert ssdp_scanner_mock.async_register_callback.await_count == 2
|
||||
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 1
|
||||
assert dmr_device_mock.async_unsubscribe_services.await_count == 0
|
||||
assert dmr_device_mock.on_event is not None
|
||||
|
||||
# Unload the config entry
|
||||
assert await hass.config_entries.async_remove(config_entry_mock.entry_id) == {
|
||||
"require_restart": False
|
||||
}
|
||||
|
||||
# Check update listeners and event notifiers are released
|
||||
assert not config_entry_mock.update_listeners
|
||||
assert domain_data_mock.async_get_event_notifier.await_count == 1
|
||||
assert domain_data_mock.async_release_event_notifier.await_count == 1
|
||||
assert ssdp_scanner_mock.async_register_callback.await_count == 2
|
||||
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 2
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 1
|
||||
assert dmr_device_mock.async_unsubscribe_services.await_count == 1
|
||||
assert dmr_device_mock.on_event is None
|
||||
|
|
|
@ -24,7 +24,7 @@ from homeassistant.components.dlna_dmr.const import (
|
|||
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.const import ATTR_ENTITY_ID, CONF_PLATFORM, CONF_URL
|
||||
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
|
||||
|
@ -32,6 +32,7 @@ from homeassistant.helpers.entity_registry import (
|
|||
async_entries_for_config_entry,
|
||||
async_get as async_get_er,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import (
|
||||
|
@ -65,7 +66,9 @@ async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry)
|
|||
@pytest.fixture
|
||||
async def mock_entity_id(
|
||||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
config_entry_mock: MockConfigEntry,
|
||||
ssdp_scanner_mock: Mock,
|
||||
dmr_device_mock: Mock,
|
||||
) -> AsyncIterable[str]:
|
||||
"""Fixture to set up a mock DlnaDmrEntity in a connected state.
|
||||
|
@ -74,8 +77,17 @@ async def mock_entity_id(
|
|||
"""
|
||||
entity_id = await setup_mock_component(hass, config_entry_mock)
|
||||
|
||||
# Check the entity has registered all needed listeners
|
||||
assert len(config_entry_mock.update_listeners) == 1
|
||||
assert domain_data_mock.async_get_event_notifier.await_count == 1
|
||||
assert domain_data_mock.async_release_event_notifier.await_count == 0
|
||||
assert ssdp_scanner_mock.async_register_callback.await_count == 2
|
||||
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 1
|
||||
assert dmr_device_mock.async_unsubscribe_services.await_count == 0
|
||||
assert dmr_device_mock.on_event is not None
|
||||
|
||||
# Run the test
|
||||
yield entity_id
|
||||
|
||||
# Unload config entry to clean up
|
||||
|
@ -83,12 +95,29 @@ async def mock_entity_id(
|
|||
"require_restart": False
|
||||
}
|
||||
|
||||
# Check entity has cleaned up its resources
|
||||
assert not config_entry_mock.update_listeners
|
||||
assert (
|
||||
domain_data_mock.async_get_event_notifier.await_count
|
||||
== domain_data_mock.async_release_event_notifier.await_count
|
||||
)
|
||||
assert (
|
||||
ssdp_scanner_mock.async_register_callback.await_count
|
||||
== ssdp_scanner_mock.async_register_callback.return_value.call_count
|
||||
)
|
||||
assert (
|
||||
dmr_device_mock.async_subscribe_services.await_count
|
||||
== dmr_device_mock.async_unsubscribe_services.await_count
|
||||
)
|
||||
assert dmr_device_mock.on_event is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_disconnected_entity_id(
|
||||
hass: HomeAssistant,
|
||||
domain_data_mock: Mock,
|
||||
config_entry_mock: MockConfigEntry,
|
||||
ssdp_scanner_mock: Mock,
|
||||
dmr_device_mock: Mock,
|
||||
) -> AsyncIterable[str]:
|
||||
"""Fixture to set up a mock DlnaDmrEntity in a disconnected state.
|
||||
|
@ -100,8 +129,19 @@ async def mock_disconnected_entity_id(
|
|||
|
||||
entity_id = await setup_mock_component(hass, config_entry_mock)
|
||||
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 0
|
||||
# Check the entity has registered all needed listeners
|
||||
assert len(config_entry_mock.update_listeners) == 1
|
||||
assert ssdp_scanner_mock.async_register_callback.await_count == 2
|
||||
assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0
|
||||
|
||||
# The DmrDevice hasn't been instantiated yet
|
||||
assert domain_data_mock.async_get_event_notifier.await_count == 0
|
||||
assert domain_data_mock.async_release_event_notifier.await_count == 0
|
||||
assert dmr_device_mock.async_subscribe_services.await_count == 0
|
||||
assert dmr_device_mock.async_unsubscribe_services.await_count == 0
|
||||
assert dmr_device_mock.on_event is None
|
||||
|
||||
# Run the test
|
||||
yield entity_id
|
||||
|
||||
# Unload config entry to clean up
|
||||
|
@ -109,6 +149,54 @@ async def mock_disconnected_entity_id(
|
|||
"require_restart": False
|
||||
}
|
||||
|
||||
# Check entity has cleaned up its resources
|
||||
assert not config_entry_mock.update_listeners
|
||||
assert (
|
||||
domain_data_mock.async_get_event_notifier.await_count
|
||||
== domain_data_mock.async_release_event_notifier.await_count
|
||||
)
|
||||
assert (
|
||||
ssdp_scanner_mock.async_register_callback.await_count
|
||||
== ssdp_scanner_mock.async_register_callback.return_value.call_count
|
||||
)
|
||||
assert (
|
||||
dmr_device_mock.async_subscribe_services.await_count
|
||||
== dmr_device_mock.async_unsubscribe_services.await_count
|
||||
)
|
||||
assert dmr_device_mock.on_event is None
|
||||
|
||||
|
||||
async def test_setup_platform_import_flow_started(
|
||||
hass: HomeAssistant, domain_data_mock: Mock
|
||||
) -> None:
|
||||
"""Test import flow of YAML config is started if there's config data."""
|
||||
# Cause connection attempts to fail
|
||||
domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError
|
||||
|
||||
# Run the setup
|
||||
mock_config: ConfigType = {
|
||||
MP_DOMAIN: [
|
||||
{
|
||||
CONF_PLATFORM: DLNA_DOMAIN,
|
||||
CONF_URL: MOCK_DEVICE_LOCATION,
|
||||
CONF_LISTEN_PORT: 1234,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await async_setup_component(hass, MP_DOMAIN, mock_config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check config_flow has started
|
||||
flows = hass.config_entries.flow.async_progress(include_uninitialized=True)
|
||||
assert len(flows) == 1
|
||||
|
||||
# It should be paused, waiting for the user to turn on the device
|
||||
flow = flows[0]
|
||||
assert flow["handler"] == "dlna_dmr"
|
||||
assert flow["step_id"] == "import_turn_on"
|
||||
assert flow["context"].get("unique_id") == MOCK_DEVICE_LOCATION
|
||||
|
||||
|
||||
async def test_setup_entry_no_options(
|
||||
hass: HomeAssistant,
|
||||
|
@ -799,7 +887,7 @@ async def test_ssdp_byebye(
|
|||
# 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
|
||||
assert mock_state.state == ha_const.STATE_UNAVAILABLE
|
||||
|
||||
# Second byebye will do nothing
|
||||
await ssdp_callback(
|
||||
|
|
Loading…
Add table
Reference in a new issue