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:
Michael Chisholm 2021-10-08 07:14:00 +11:00 committed by GitHub
parent b980dc7e33
commit 667e730946
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 436 additions and 436 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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%]"
}

View file

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

View file

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

View file

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

View file

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

View file

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