Use ssdp callbacks in upnp (#53840)

This commit is contained in:
Steven Looman 2021-08-13 18:13:25 +02:00 committed by GitHub
parent 3454102dc8
commit 2c1728022d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 531 additions and 444 deletions

View File

@ -2,7 +2,7 @@
"domain": "dlna_dmr", "domain": "dlna_dmr",
"name": "DLNA Digital Media Renderer", "name": "DLNA Digital Media Renderer",
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"requirements": ["async-upnp-client==0.19.1"], "requirements": ["async-upnp-client==0.19.2"],
"dependencies": ["network"], "dependencies": ["network"],
"codeowners": [], "codeowners": [],
"iot_class": "local_push" "iot_class": "local_push"

View File

@ -9,6 +9,7 @@ import logging
from typing import Any, Callable from typing import Any, Callable
from async_upnp_client.search import SSDPListener from async_upnp_client.search import SSDPListener
from async_upnp_client.ssdp import SSDP_PORT
from async_upnp_client.utils import CaseInsensitiveDict from async_upnp_client.utils import CaseInsensitiveDict
from homeassistant import config_entries from homeassistant import config_entries
@ -228,6 +229,21 @@ class Scanner:
for listener in self._ssdp_listeners: for listener in self._ssdp_listeners:
listener.async_search() listener.async_search()
self.async_scan_broadcast()
@core_callback
def async_scan_broadcast(self, *_: Any) -> None:
"""Scan for new entries using broadcast target."""
# Some sonos devices only seem to respond if we send to the broadcast
# address. This matches pysonos' behavior
# https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120
for listener in self._ssdp_listeners:
try:
IPv4Address(listener.source_ip)
except ValueError:
continue
listener.async_search((str(IPV4_BROADCAST), SSDP_PORT))
async def async_start(self) -> None: async def async_start(self) -> None:
"""Start the scanner.""" """Start the scanner."""
self.description_manager = DescriptionManager(self.hass) self.description_manager = DescriptionManager(self.hass)
@ -238,20 +254,6 @@ class Scanner:
async_callback=self._async_process_entry, source_ip=source_ip async_callback=self._async_process_entry, source_ip=source_ip
) )
) )
try:
IPv4Address(source_ip)
except ValueError:
continue
# Some sonos devices only seem to respond if we send to the broadcast
# address. This matches pysonos' behavior
# https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120
self._ssdp_listeners.append(
SSDPListener(
async_callback=self._async_process_entry,
source_ip=source_ip,
target_ip=IPV4_BROADCAST,
)
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
self.hass.bus.async_listen_once( self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start
@ -275,6 +277,10 @@ class Scanner:
self.hass, self.async_scan, SCAN_INTERVAL self.hass, self.async_scan, SCAN_INTERVAL
) )
# Trigger a broadcast-scan. Regular scan is implicitly triggered
# by SSDPListener.
self.async_scan_broadcast()
@core_callback @core_callback
def _async_get_matching_callbacks( def _async_get_matching_callbacks(
self, headers: Mapping[str, str] self, headers: Mapping[str, str]

View File

@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/ssdp", "documentation": "https://www.home-assistant.io/integrations/ssdp",
"requirements": [ "requirements": [
"defusedxml==0.7.1", "defusedxml==0.7.1",
"async-upnp-client==0.19.1" "async-upnp-client==0.19.2"
], ],
"dependencies": ["network"], "dependencies": ["network"],
"after_dependencies": ["zeroconf"], "after_dependencies": ["zeroconf"],

View File

@ -1,6 +1,10 @@
"""Open ports in your router for Home Assistant and provide statistics.""" """Open ports in your router for Home Assistant and provide statistics."""
from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
from ipaddress import ip_address from ipaddress import ip_address
from typing import Any
import voluptuous as vol import voluptuous as vol
@ -9,7 +13,7 @@ from homeassistant.components import ssdp
from homeassistant.components.network import async_get_source_ip from homeassistant.components.network import async_get_source_ip
from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.network.const import PUBLIC_TARGET_IP
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -44,21 +48,6 @@ CONFIG_SCHEMA = vol.Schema(
) )
async def async_construct_device(hass: HomeAssistant, udn: str, st: str) -> Device:
"""Discovery devices and construct a Device for one."""
# pylint: disable=invalid-name
_LOGGER.debug("Constructing device: %s::%s", udn, st)
discovery_info = ssdp.async_get_discovery_info_by_udn_st(hass, udn, st)
if not discovery_info:
_LOGGER.info("Device not discovered")
return None
return await Device.async_create_device(
hass, discovery_info[ssdp.ATTR_SSDP_LOCATION]
)
async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup(hass: HomeAssistant, config: ConfigType):
"""Set up UPnP component.""" """Set up UPnP component."""
_LOGGER.debug("async_setup, config: %s", config) _LOGGER.debug("async_setup, config: %s", config)
@ -86,20 +75,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up UPnP/IGD device from a config entry.""" """Set up UPnP/IGD device from a config entry."""
_LOGGER.debug("Setting up config entry: %s", entry.unique_id) _LOGGER.debug("Setting up config entry: %s", entry.unique_id)
# Discover and construct.
udn = entry.data[CONFIG_ENTRY_UDN] udn = entry.data[CONFIG_ENTRY_UDN]
st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name
try: usn = f"{udn}::{st}"
device = await async_construct_device(hass, udn, st)
except asyncio.TimeoutError as err:
raise ConfigEntryNotReady from err
if not device: # Register device discovered-callback.
_LOGGER.info("Unable to create UPnP/IGD, aborting") device_discovered_event = asyncio.Event()
raise ConfigEntryNotReady discovery_info: Mapping[str, Any] | None = None
@callback
def device_discovered(info: Mapping[str, Any]) -> None:
nonlocal discovery_info
_LOGGER.debug(
"Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION]
)
discovery_info = info
device_discovered_event.set()
cancel_discovered_callback = ssdp.async_register_callback(
hass,
device_discovered,
{
"usn": usn,
},
)
try:
await asyncio.wait_for(device_discovered_event.wait(), timeout=10)
except asyncio.TimeoutError as err:
_LOGGER.debug("Device not discovered: %s", usn)
raise ConfigEntryNotReady from err
finally:
cancel_discovered_callback()
# Create device.
location = discovery_info[ # pylint: disable=unsubscriptable-object
ssdp.ATTR_SSDP_LOCATION
]
device = await Device.async_create_device(hass, location)
# Save device. # Save device.
hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device hass.data[DOMAIN][DOMAIN_DEVICES][udn] = device
# Ensure entry has a unique_id. # Ensure entry has a unique_id.
if not entry.unique_id: if not entry.unique_id:

View File

@ -1,6 +1,7 @@
"""Config flow for UPNP.""" """Config flow for UPNP."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
@ -10,7 +11,7 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from .const import ( from .const import (
CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_HOSTNAME,
@ -18,18 +19,70 @@ from .const import (
CONFIG_ENTRY_ST, CONFIG_ENTRY_ST,
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DISCOVERY_HOSTNAME,
DISCOVERY_LOCATION,
DISCOVERY_NAME,
DISCOVERY_ST,
DISCOVERY_UDN,
DISCOVERY_UNIQUE_ID,
DISCOVERY_USN,
DOMAIN, DOMAIN,
DOMAIN_DEVICES, DOMAIN_DEVICES,
LOGGER as _LOGGER, LOGGER as _LOGGER,
SSDP_SEARCH_TIMEOUT,
ST_IGD_V1,
ST_IGD_V2,
) )
from .device import Device, discovery_info_to_discovery
def _friendly_name_from_discovery(discovery_info: Mapping[str, Any]) -> str:
"""Extract user-friendly name from discovery."""
return (
discovery_info.get("friendlyName")
or discovery_info.get("modeName")
or discovery_info.get("_host", "")
)
async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool:
"""Wait for a device to be discovered."""
device_discovered_event = asyncio.Event()
@callback
def device_discovered(info: Mapping[str, Any]) -> None:
_LOGGER.info(
"Device discovered: %s, at: %s",
info[ssdp.ATTR_SSDP_USN],
info[ssdp.ATTR_SSDP_LOCATION],
)
device_discovered_event.set()
cancel_discovered_callback_1 = ssdp.async_register_callback(
hass,
device_discovered,
{
ssdp.ATTR_SSDP_ST: ST_IGD_V1,
},
)
cancel_discovered_callback_2 = ssdp.async_register_callback(
hass,
device_discovered,
{
ssdp.ATTR_SSDP_ST: ST_IGD_V2,
},
)
try:
await asyncio.wait_for(
device_discovered_event.wait(), timeout=SSDP_SEARCH_TIMEOUT
)
except asyncio.TimeoutError:
return False
finally:
cancel_discovered_callback_1()
cancel_discovered_callback_2()
return True
def _discovery_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]:
"""Discovery IGD devices."""
return ssdp.async_get_discovery_info_by_st(
hass, ST_IGD_V1
) + ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2)
class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -57,22 +110,19 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
matching_discoveries = [ matching_discoveries = [
discovery discovery
for discovery in self._discoveries for discovery in self._discoveries
if discovery[DISCOVERY_UNIQUE_ID] == user_input["unique_id"] if discovery[ssdp.ATTR_SSDP_USN] == user_input["unique_id"]
] ]
if not matching_discoveries: if not matching_discoveries:
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
discovery = matching_discoveries[0] discovery = matching_discoveries[0]
await self.async_set_unique_id( await self.async_set_unique_id(
discovery[DISCOVERY_UNIQUE_ID], raise_on_progress=False discovery[ssdp.ATTR_SSDP_USN], raise_on_progress=False
) )
return await self._async_create_entry_from_discovery(discovery) return await self._async_create_entry_from_discovery(discovery)
# Discover devices. # Discover devices.
discoveries = [ discoveries = _discovery_igd_devices(self.hass)
await Device.async_supplement_discovery(self.hass, discovery)
for discovery in await Device.async_discover(self.hass)
]
# Store discoveries which have not been configured. # Store discoveries which have not been configured.
current_unique_ids = { current_unique_ids = {
@ -81,7 +131,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._discoveries = [ self._discoveries = [
discovery discovery
for discovery in discoveries for discovery in discoveries
if discovery[DISCOVERY_UNIQUE_ID] not in current_unique_ids if discovery[ssdp.ATTR_SSDP_USN] not in current_unique_ids
] ]
# Ensure anything to add. # Ensure anything to add.
@ -92,7 +142,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
{ {
vol.Required("unique_id"): vol.In( vol.Required("unique_id"): vol.In(
{ {
discovery[DISCOVERY_UNIQUE_ID]: discovery[DISCOVERY_NAME] discovery[ssdp.ATTR_SSDP_USN]: _friendly_name_from_discovery(
discovery
)
for discovery in self._discoveries for discovery in self._discoveries
} }
), ),
@ -119,27 +171,27 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_configured") return self.async_abort(reason="already_configured")
# Discover devices. # Discover devices.
self._discoveries = await Device.async_discover(self.hass) await _async_wait_for_discoveries(self.hass)
discoveries = _discovery_igd_devices(self.hass)
# Ensure anything to add. If not, silently abort. # Ensure anything to add. If not, silently abort.
if not self._discoveries: if not discoveries:
_LOGGER.info("No UPnP devices discovered, aborting") _LOGGER.info("No UPnP devices discovered, aborting")
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
# Ensure complete discovery. # Ensure complete discovery.
discovery = self._discoveries[0] discovery = discoveries[0]
if ( if (
DISCOVERY_UDN not in discovery ssdp.ATTR_UPNP_UDN not in discovery
or DISCOVERY_ST not in discovery or ssdp.ATTR_SSDP_ST not in discovery
or DISCOVERY_LOCATION not in discovery or ssdp.ATTR_SSDP_LOCATION not in discovery
or DISCOVERY_USN not in discovery or ssdp.ATTR_SSDP_USN not in discovery
): ):
_LOGGER.debug("Incomplete discovery, ignoring") _LOGGER.debug("Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery") return self.async_abort(reason="incomplete_discovery")
# Ensure not already configuring/configured. # Ensure not already configuring/configured.
discovery = await Device.async_supplement_discovery(self.hass, discovery) unique_id = discovery[ssdp.ATTR_SSDP_USN]
unique_id = discovery[DISCOVERY_UNIQUE_ID]
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
return await self._async_create_entry_from_discovery(discovery) return await self._async_create_entry_from_discovery(discovery)
@ -162,35 +214,28 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Incomplete discovery, ignoring") _LOGGER.debug("Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery") return self.async_abort(reason="incomplete_discovery")
# Convert to something we understand/speak.
discovery = discovery_info_to_discovery(discovery_info)
# Ensure not already configuring/configured. # Ensure not already configuring/configured.
unique_id = discovery[DISCOVERY_USN] unique_id = discovery_info[ssdp.ATTR_SSDP_USN]
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured( hostname = discovery_info["_host"]
updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} self._abort_if_unique_id_configured(updates={CONFIG_ENTRY_HOSTNAME: hostname})
)
# Handle devices changing their UDN, only allow a single # Handle devices changing their UDN, only allow a single host.
existing_entries = self._async_current_entries() existing_entries = self._async_current_entries()
for config_entry in existing_entries: for config_entry in existing_entries:
entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME)
if entry_hostname == discovery[DISCOVERY_HOSTNAME]: if entry_hostname == hostname:
_LOGGER.debug( _LOGGER.debug(
"Found existing config_entry with same hostname, discovery ignored" "Found existing config_entry with same hostname, discovery ignored"
) )
return self.async_abort(reason="discovery_ignored") return self.async_abort(reason="discovery_ignored")
# Get more data about the device.
discovery = await Device.async_supplement_discovery(self.hass, discovery)
# Store discovery. # Store discovery.
self._discoveries = [discovery] self._discoveries = [discovery_info]
# Ensure user recognizable. # Ensure user recognizable.
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": discovery[DISCOVERY_NAME], "name": _friendly_name_from_discovery(discovery_info),
} }
return await self.async_step_ssdp_confirm() return await self.async_step_ssdp_confirm()
@ -224,11 +269,11 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
discovery, discovery,
) )
title = discovery.get(DISCOVERY_NAME, "") title = _friendly_name_from_discovery(discovery)
data = { data = {
CONFIG_ENTRY_UDN: discovery[DISCOVERY_UDN], CONFIG_ENTRY_UDN: discovery["_udn"],
CONFIG_ENTRY_ST: discovery[DISCOVERY_ST], CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST],
CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME], CONFIG_ENTRY_HOSTNAME: discovery["_host"],
} }
return self.async_create_entry(title=title, data=data) return self.async_create_entry(title=title, data=data)

View File

@ -20,15 +20,11 @@ DATA_PACKETS = "packets"
DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}"
KIBIBYTE = 1024 KIBIBYTE = 1024
UPDATE_INTERVAL = timedelta(seconds=30) UPDATE_INTERVAL = timedelta(seconds=30)
DISCOVERY_HOSTNAME = "hostname"
DISCOVERY_LOCATION = "location"
DISCOVERY_NAME = "name"
DISCOVERY_ST = "st"
DISCOVERY_UDN = "udn"
DISCOVERY_UNIQUE_ID = "unique_id"
DISCOVERY_USN = "usn"
CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval"
CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_ST = "st"
CONFIG_ENTRY_UDN = "udn" CONFIG_ENTRY_UDN = "udn"
CONFIG_ENTRY_HOSTNAME = "hostname" CONFIG_ENTRY_HOSTNAME = "hostname"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds() DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds()
ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2"
SSDP_SEARCH_TIMEOUT = 4

View File

@ -12,7 +12,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.device_updater import DeviceUpdater from async_upnp_client.device_updater import DeviceUpdater
from async_upnp_client.profiles.igd import IgdDevice from async_upnp_client.profiles.igd import IgdDevice
from homeassistant.components import ssdp
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -22,13 +21,6 @@ from .const import (
BYTES_RECEIVED, BYTES_RECEIVED,
BYTES_SENT, BYTES_SENT,
CONF_LOCAL_IP, CONF_LOCAL_IP,
DISCOVERY_HOSTNAME,
DISCOVERY_LOCATION,
DISCOVERY_NAME,
DISCOVERY_ST,
DISCOVERY_UDN,
DISCOVERY_UNIQUE_ID,
DISCOVERY_USN,
DOMAIN, DOMAIN,
DOMAIN_CONFIG, DOMAIN_CONFIG,
LOGGER as _LOGGER, LOGGER as _LOGGER,
@ -38,20 +30,6 @@ from .const import (
) )
def discovery_info_to_discovery(discovery_info: Mapping) -> Mapping:
"""Convert a SSDP-discovery to 'our' discovery."""
location = discovery_info[ssdp.ATTR_SSDP_LOCATION]
parsed = urlparse(location)
hostname = parsed.hostname
return {
DISCOVERY_UDN: discovery_info[ssdp.ATTR_UPNP_UDN],
DISCOVERY_ST: discovery_info[ssdp.ATTR_SSDP_ST],
DISCOVERY_LOCATION: discovery_info[ssdp.ATTR_SSDP_LOCATION],
DISCOVERY_USN: discovery_info[ssdp.ATTR_SSDP_USN],
DISCOVERY_HOSTNAME: hostname,
}
def _get_local_ip(hass: HomeAssistant) -> IPv4Address | None: def _get_local_ip(hass: HomeAssistant) -> IPv4Address | None:
"""Get the configured local ip.""" """Get the configured local ip."""
if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]:
@ -70,29 +48,6 @@ class Device:
self._device_updater = device_updater self._device_updater = device_updater
self.coordinator: DataUpdateCoordinator = None self.coordinator: DataUpdateCoordinator = None
@classmethod
async def async_discover(cls, hass: HomeAssistant) -> list[Mapping]:
"""Discover UPnP/IGD devices."""
_LOGGER.debug("Discovering UPnP/IGD devices")
discoveries = []
for ssdp_st in IgdDevice.DEVICE_TYPES:
for discovery_info in ssdp.async_get_discovery_info_by_st(hass, ssdp_st):
discoveries.append(discovery_info_to_discovery(discovery_info))
return discoveries
@classmethod
async def async_supplement_discovery(
cls, hass: HomeAssistant, discovery: Mapping
) -> Mapping:
"""Get additional data from device and supplement discovery."""
location = discovery[DISCOVERY_LOCATION]
device = await Device.async_create_device(hass, location)
discovery[DISCOVERY_NAME] = device.name
discovery[DISCOVERY_HOSTNAME] = device.hostname
discovery[DISCOVERY_UNIQUE_ID] = discovery[DISCOVERY_USN]
return discovery
@classmethod @classmethod
async def async_create_device( async def async_create_device(
cls, hass: HomeAssistant, ssdp_location: str cls, hass: HomeAssistant, ssdp_location: str

View File

@ -3,7 +3,7 @@
"name": "UPnP/IGD", "name": "UPnP/IGD",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/upnp", "documentation": "https://www.home-assistant.io/integrations/upnp",
"requirements": ["async-upnp-client==0.19.1"], "requirements": ["async-upnp-client==0.19.2"],
"dependencies": ["network", "ssdp"], "dependencies": ["network", "ssdp"],
"codeowners": ["@StevenLooman"], "codeowners": ["@StevenLooman"],
"ssdp": [ "ssdp": [

View File

@ -4,7 +4,7 @@ aiodiscover==1.4.2
aiohttp==3.7.4.post0 aiohttp==3.7.4.post0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
astral==2.2 astral==2.2
async-upnp-client==0.19.1 async-upnp-client==0.19.2
async_timeout==3.0.1 async_timeout==3.0.1
attrs==21.2.0 attrs==21.2.0
awesomeversion==21.4.0 awesomeversion==21.4.0

View File

@ -311,7 +311,7 @@ asterisk_mbox==0.5.0
# homeassistant.components.dlna_dmr # homeassistant.components.dlna_dmr
# homeassistant.components.ssdp # homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
async-upnp-client==0.19.1 async-upnp-client==0.19.2
# homeassistant.components.supla # homeassistant.components.supla
asyncpysupla==0.0.5 asyncpysupla==0.0.5

View File

@ -202,7 +202,7 @@ arcam-fmj==0.7.0
# homeassistant.components.dlna_dmr # homeassistant.components.dlna_dmr
# homeassistant.components.ssdp # homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
async-upnp-client==0.19.1 async-upnp-client==0.19.2
# homeassistant.components.aurora # homeassistant.components.aurora
auroranoaa==0.0.2 auroranoaa==0.0.2

View File

@ -29,7 +29,13 @@ def _patched_ssdp_listener(info, *args, **kwargs):
async def _async_callback(*_): async def _async_callback(*_):
await listener.async_callback(info) await listener.async_callback(info)
@callback
def _async_search(*_):
# Prevent an actual scan.
pass
listener.async_start = _async_callback listener.async_start = _async_callback
listener.async_search = _async_search
return listener return listener
@ -287,7 +293,10 @@ async def test_invalid_characters(hass, aioclient_mock):
@patch("homeassistant.components.ssdp.SSDPListener.async_start") @patch("homeassistant.components.ssdp.SSDPListener.async_start")
@patch("homeassistant.components.ssdp.SSDPListener.async_search") @patch("homeassistant.components.ssdp.SSDPListener.async_search")
async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): @patch("homeassistant.components.ssdp.SSDPListener.async_stop")
async def test_start_stop_scanner(
async_stop_mock, async_search_mock, async_start_mock, hass
):
"""Test we start and stop the scanner.""" """Test we start and stop the scanner."""
assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}})
@ -295,15 +304,18 @@ async def test_start_stop_scanner(async_start_mock, async_search_mock, hass):
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done() await hass.async_block_till_done()
assert async_start_mock.call_count == 2 assert async_start_mock.call_count == 1
assert async_search_mock.call_count == 2 # Next is 3, as async_upnp_client triggers 1 SSDPListener._async_on_connect
assert async_search_mock.call_count == 3
assert async_stop_mock.call_count == 0
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done() await hass.async_block_till_done()
assert async_start_mock.call_count == 2 assert async_start_mock.call_count == 1
assert async_search_mock.call_count == 2 assert async_search_mock.call_count == 3
assert async_stop_mock.call_count == 1
async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog):
@ -787,7 +799,6 @@ async def test_async_detect_interfaces_setting_empty_route(hass):
assert argset == { assert argset == {
(IPv6Address("2001:db8::"), None), (IPv6Address("2001:db8::"), None),
(IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")),
(IPv4Address("192.168.1.5"), None), (IPv4Address("192.168.1.5"), None),
} }
@ -802,12 +813,12 @@ async def test_bind_failure_skips_adapter(hass, caplog):
] ]
} }
create_args = [] create_args = []
did_search = 0 search_args = []
@callback @callback
def _callback(*_): def _callback(*args):
nonlocal did_search nonlocal search_args
did_search += 1 search_args.append(args)
pass pass
def _generate_failing_ssdp_listener(*args, **kwargs): def _generate_failing_ssdp_listener(*args, **kwargs):
@ -844,11 +855,74 @@ async def test_bind_failure_skips_adapter(hass, caplog):
assert argset == { assert argset == {
(IPv6Address("2001:db8::"), None), (IPv6Address("2001:db8::"), None),
(IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")),
(IPv4Address("192.168.1.5"), None), (IPv4Address("192.168.1.5"), None),
} }
assert "Failed to setup listener for" in caplog.text assert "Failed to setup listener for" in caplog.text
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done() await hass.async_block_till_done()
assert did_search == 2 assert set(search_args) == {
(),
(
(
"255.255.255.255",
1900,
),
),
}
async def test_ipv4_does_additional_search_for_sonos(hass, caplog):
"""Test that only ipv4 does an additional search for Sonos."""
mock_get_ssdp = {
"mock-domain": [
{
ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC",
}
]
}
search_args = []
def _generate_fake_ssdp_listener(*args, **kwargs):
listener = SSDPListener(*args, **kwargs)
async def _async_callback(*_):
pass
@callback
def _callback(*args):
nonlocal search_args
search_args.append(args)
pass
listener.async_start = _async_callback
listener.async_search = _callback
return listener
with patch(
"homeassistant.components.ssdp.async_get_ssdp",
return_value=mock_get_ssdp,
), patch(
"homeassistant.components.ssdp.SSDPListener",
new=_generate_fake_ssdp_listener,
), patch(
"homeassistant.components.ssdp.network.async_get_adapters",
return_value=_ADAPTERS_WITH_MANUAL_CONFIG,
):
assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}})
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done()
assert set(search_args) == {
(),
(
(
"255.255.255.255",
1900,
),
),
}

View File

@ -0,0 +1,23 @@
"""Common for upnp."""
from urllib.parse import urlparse
from homeassistant.components import ssdp
TEST_UDN = "uuid:device"
TEST_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
TEST_USN = f"{TEST_UDN}::{TEST_ST}"
TEST_LOCATION = "http://192.168.1.1/desc.xml"
TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname
TEST_FRIENDLY_NAME = "friendly name"
TEST_DISCOVERY = {
ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION,
ssdp.ATTR_SSDP_ST: TEST_ST,
ssdp.ATTR_SSDP_USN: TEST_USN,
ssdp.ATTR_UPNP_UDN: TEST_UDN,
"usn": TEST_USN,
"location": TEST_LOCATION,
"_host": TEST_HOSTNAME,
"_udn": TEST_UDN,
"friendlyName": TEST_FRIENDLY_NAME,
}

View File

@ -0,0 +1,49 @@
"""Mock ssdp.Scanner."""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components import ssdp
from homeassistant.core import callback
class MockSsdpDescriptionManager(ssdp.DescriptionManager):
"""Mocked ssdp DescriptionManager."""
async def fetch_description(
self, xml_location: str | None
) -> None | dict[str, str]:
"""Fetch the location or get it from the cache."""
if xml_location is None:
return None
return {}
class MockSsdpScanner(ssdp.Scanner):
"""Mocked ssdp Scanner."""
@callback
def async_stop(self, *_: Any) -> None:
"""Stop the scanner."""
# Do nothing.
async def async_start(self) -> None:
"""Start the scanner."""
self.description_manager = MockSsdpDescriptionManager(self.hass)
@callback
def async_scan(self, *_: Any) -> None:
"""Scan for new entries."""
# Do nothing.
@pytest.fixture
def mock_ssdp_scanner():
"""Mock ssdp Scanner."""
with patch(
"homeassistant.components.ssdp.Scanner", new=MockSsdpScanner
) as mock_ssdp_scanner:
yield mock_ssdp_scanner

View File

@ -1,7 +1,9 @@
"""Mock device for testing purposes.""" """Mock device for testing purposes."""
from typing import Any, Mapping from typing import Any, Mapping
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.upnp.const import ( from homeassistant.components.upnp.const import (
BYTES_RECEIVED, BYTES_RECEIVED,
@ -13,6 +15,8 @@ from homeassistant.components.upnp.const import (
from homeassistant.components.upnp.device import Device from homeassistant.components.upnp.device import Device
from homeassistant.util import dt from homeassistant.util import dt
from .common import TEST_UDN
class MockDevice(Device): class MockDevice(Device):
"""Mock device for Device.""" """Mock device for Device."""
@ -28,7 +32,7 @@ class MockDevice(Device):
@classmethod @classmethod
async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": async def async_create_device(cls, hass, ssdp_location) -> "MockDevice":
"""Return self.""" """Return self."""
return cls("UDN") return cls(TEST_UDN)
@property @property
def udn(self) -> str: def udn(self) -> str:
@ -70,3 +74,18 @@ class MockDevice(Device):
PACKETS_RECEIVED: 0, PACKETS_RECEIVED: 0,
PACKETS_SENT: 0, PACKETS_SENT: 0,
} }
async def async_start(self) -> None:
"""Start the device updater."""
async def async_stop(self) -> None:
"""Stop the device updater."""
@pytest.fixture
def mock_upnp_device():
"""Mock upnp Device.async_create_device."""
with patch(
"homeassistant.components.upnp.Device", new=MockDevice
) as mock_async_create_device:
yield mock_async_create_device

View File

@ -1,8 +1,9 @@
"""Test UPnP/IGD config flow.""" """Test UPnP/IGD config flow."""
from datetime import timedelta from datetime import timedelta
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import patch
from urllib.parse import urlparse
import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components import ssdp from homeassistant.components import ssdp
@ -12,66 +13,46 @@ from homeassistant.components.upnp.const import (
CONFIG_ENTRY_ST, CONFIG_ENTRY_ST,
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DISCOVERY_HOSTNAME,
DISCOVERY_LOCATION,
DISCOVERY_NAME,
DISCOVERY_ST,
DISCOVERY_UDN,
DISCOVERY_UNIQUE_ID,
DISCOVERY_USN,
DOMAIN, DOMAIN,
DOMAIN_DEVICES,
) )
from homeassistant.components.upnp.device import Device from homeassistant.core import CoreState, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt from homeassistant.util import dt
from .mock_device import MockDevice from .common import (
TEST_DISCOVERY,
TEST_FRIENDLY_NAME,
TEST_HOSTNAME,
TEST_LOCATION,
TEST_ST,
TEST_UDN,
TEST_USN,
)
from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401
from .mock_upnp_device import mock_upnp_device # noqa: F401
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
async def test_flow_ssdp_discovery(hass: HomeAssistant): @pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device")
"""Test config flow: discovered + configured through ssdp.""" async def test_flow_ssdp_discovery(
udn = "uuid:device_1" hass: HomeAssistant,
location = "http://dummy"
mock_device = MockDevice(udn)
ssdp_discoveries = [
{
ssdp.ATTR_SSDP_LOCATION: location,
ssdp.ATTR_SSDP_ST: mock_device.device_type,
ssdp.ATTR_UPNP_UDN: mock_device.udn,
ssdp.ATTR_SSDP_USN: mock_device.usn,
}
]
discoveries = [
{
DISCOVERY_LOCATION: location,
DISCOVERY_NAME: mock_device.name,
DISCOVERY_ST: mock_device.device_type,
DISCOVERY_UDN: mock_device.udn,
DISCOVERY_UNIQUE_ID: mock_device.unique_id,
DISCOVERY_USN: mock_device.usn,
DISCOVERY_HOSTNAME: mock_device.hostname,
}
]
with patch.object(
Device, "async_create_device", AsyncMock(return_value=mock_device)
), patch.object(
ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries)
), patch.object(
Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0])
): ):
"""Test config flow: discovered + configured through ssdp."""
# Ensure we have a ssdp Scanner.
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY
# Speed up callback in ssdp.async_register_callback.
hass.state = CoreState.not_running
# Discovered via step ssdp. # Discovered via step ssdp.
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_SSDP}, context={"source": config_entries.SOURCE_SSDP},
data={ data=TEST_DISCOVERY,
ssdp.ATTR_SSDP_LOCATION: location,
ssdp.ATTR_SSDP_ST: mock_device.device_type,
ssdp.ATTR_SSDP_USN: mock_device.usn,
ssdp.ATTR_UPNP_UDN: mock_device.udn,
},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "ssdp_confirm" assert result["step_id"] == "ssdp_confirm"
@ -81,50 +62,43 @@ async def test_flow_ssdp_discovery(hass: HomeAssistant):
result["flow_id"], result["flow_id"],
user_input={}, user_input={},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == mock_device.name assert result["title"] == TEST_FRIENDLY_NAME
assert result["data"] == { assert result["data"] == {
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_HOSTNAME: mock_device.hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
} }
@pytest.mark.usefixtures("mock_ssdp_scanner")
async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant):
"""Test config flow: incomplete discovery through ssdp.""" """Test config flow: incomplete discovery through ssdp."""
udn = "uuid:device_1"
location = "http://dummy"
mock_device = MockDevice(udn)
# Discovered via step ssdp. # Discovered via step ssdp.
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_SSDP}, context={"source": config_entries.SOURCE_SSDP},
data={ data={
ssdp.ATTR_SSDP_LOCATION: location, ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION,
ssdp.ATTR_SSDP_ST: mock_device.device_type, ssdp.ATTR_SSDP_ST: TEST_ST,
ssdp.ATTR_SSDP_USN: mock_device.usn, ssdp.ATTR_SSDP_USN: TEST_USN,
# ssdp.ATTR_UPNP_UDN: mock_device.udn, # Not provided. # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided.
}, },
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "incomplete_discovery" assert result["reason"] == "incomplete_discovery"
@pytest.mark.usefixtures("mock_ssdp_scanner")
async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant):
"""Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry."""
udn = "uuid:device_random_1"
location = "http://dummy"
mock_device = MockDevice(udn)
# Existing entry. # Existing entry.
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONFIG_ENTRY_UDN: "uuid:device_random_2", CONFIG_ENTRY_UDN: TEST_UDN + "2",
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_HOSTNAME: urlparse(location).hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
}, },
options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
) )
@ -134,49 +108,23 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_SSDP}, context={"source": config_entries.SOURCE_SSDP},
data={ data=TEST_DISCOVERY,
ssdp.ATTR_SSDP_LOCATION: location,
ssdp.ATTR_SSDP_ST: mock_device.device_type,
ssdp.ATTR_SSDP_USN: mock_device.usn,
ssdp.ATTR_UPNP_UDN: mock_device.udn,
},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "discovery_ignored" assert result["reason"] == "discovery_ignored"
@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device")
async def test_flow_user(hass: HomeAssistant): async def test_flow_user(hass: HomeAssistant):
"""Test config flow: discovered + configured through user.""" """Test config flow: discovered + configured through user."""
udn = "uuid:device_1" # Ensure we have a ssdp Scanner.
location = "http://dummy" await async_setup_component(hass, DOMAIN, {})
mock_device = MockDevice(udn) await hass.async_block_till_done()
ssdp_discoveries = [ ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
{ ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY
ssdp.ATTR_SSDP_LOCATION: location, # Speed up callback in ssdp.async_register_callback.
ssdp.ATTR_SSDP_ST: mock_device.device_type, hass.state = CoreState.not_running
ssdp.ATTR_UPNP_UDN: mock_device.udn,
ssdp.ATTR_SSDP_USN: mock_device.usn,
}
]
discoveries = [
{
DISCOVERY_LOCATION: location,
DISCOVERY_NAME: mock_device.name,
DISCOVERY_ST: mock_device.device_type,
DISCOVERY_UDN: mock_device.udn,
DISCOVERY_UNIQUE_ID: mock_device.unique_id,
DISCOVERY_USN: mock_device.usn,
DISCOVERY_HOSTNAME: mock_device.hostname,
}
]
with patch.object(
Device, "async_create_device", AsyncMock(return_value=mock_device)
), patch.object(
ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries)
), patch.object(
Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0])
):
# Discovered via step user. # Discovered via step user.
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -187,76 +135,51 @@ async def test_flow_user(hass: HomeAssistant):
# Confirmed via step user. # Confirmed via step user.
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={"unique_id": mock_device.unique_id}, user_input={"unique_id": TEST_USN},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == mock_device.name assert result["title"] == TEST_FRIENDLY_NAME
assert result["data"] == { assert result["data"] == {
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_HOSTNAME: mock_device.hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
} }
@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device")
async def test_flow_import(hass: HomeAssistant): async def test_flow_import(hass: HomeAssistant):
"""Test config flow: discovered + configured through configuration.yaml.""" """Test config flow: configured through configuration.yaml."""
udn = "uuid:device_1" # Ensure we have a ssdp Scanner.
mock_device = MockDevice(udn) await async_setup_component(hass, DOMAIN, {})
location = "http://dummy" await hass.async_block_till_done()
ssdp_discoveries = [ ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
{ ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY
ssdp.ATTR_SSDP_LOCATION: location, # Speed up callback in ssdp.async_register_callback.
ssdp.ATTR_SSDP_ST: mock_device.device_type, hass.state = CoreState.not_running
ssdp.ATTR_UPNP_UDN: mock_device.udn,
ssdp.ATTR_SSDP_USN: mock_device.usn,
}
]
discoveries = [
{
DISCOVERY_LOCATION: location,
DISCOVERY_NAME: mock_device.name,
DISCOVERY_ST: mock_device.device_type,
DISCOVERY_UDN: mock_device.udn,
DISCOVERY_UNIQUE_ID: mock_device.unique_id,
DISCOVERY_USN: mock_device.usn,
DISCOVERY_HOSTNAME: mock_device.hostname,
}
]
with patch.object(
Device, "async_create_device", AsyncMock(return_value=mock_device)
), patch.object(
ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries)
), patch.object(
Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0])
):
# Discovered via step import. # Discovered via step import.
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT} DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == mock_device.name assert result["title"] == TEST_FRIENDLY_NAME
assert result["data"] == { assert result["data"] == {
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_HOSTNAME: mock_device.hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
} }
@pytest.mark.usefixtures("mock_ssdp_scanner")
async def test_flow_import_already_configured(hass: HomeAssistant): async def test_flow_import_already_configured(hass: HomeAssistant):
"""Test config flow: discovered, but already configured.""" """Test config flow: configured through configuration.yaml, but existing config entry."""
udn = "uuid:device_1"
mock_device = MockDevice(udn)
# Existing entry. # Existing entry.
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_HOSTNAME: mock_device.hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
}, },
options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
) )
@ -271,60 +194,54 @@ async def test_flow_import_already_configured(hass: HomeAssistant):
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_ssdp_scanner")
async def test_flow_import_no_devices_found(hass: HomeAssistant): async def test_flow_import_no_devices_found(hass: HomeAssistant):
"""Test config flow: no devices found, configured through configuration.yaml.""" """Test config flow: no devices found, configured through configuration.yaml."""
ssdp_discoveries = [] # Ensure we have a ssdp Scanner.
with patch.object( await async_setup_component(hass, DOMAIN, {})
ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) await hass.async_block_till_done()
): ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
ssdp_scanner.cache.clear()
# Discovered via step import. # Discovered via step import.
with patch(
"homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", new=0.0
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT} DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "no_devices_found" assert result["reason"] == "no_devices_found"
@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device")
async def test_options_flow(hass: HomeAssistant): async def test_options_flow(hass: HomeAssistant):
"""Test options flow.""" """Test options flow."""
# Ensure we have a ssdp Scanner.
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY
# Speed up callback in ssdp.async_register_callback.
hass.state = CoreState.not_running
# Set up config entry. # Set up config entry.
udn = "uuid:device_1"
location = "http://192.168.1.1/desc.xml"
mock_device = MockDevice(udn)
ssdp_discoveries = [
{
ssdp.ATTR_SSDP_LOCATION: location,
ssdp.ATTR_SSDP_ST: mock_device.device_type,
ssdp.ATTR_UPNP_UDN: mock_device.udn,
ssdp.ATTR_SSDP_USN: mock_device.usn,
}
]
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
CONFIG_ENTRY_HOSTNAME: mock_device.hostname, CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME,
}, },
options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
config = {
# no upnp, ensures no import-flow is started.
}
with patch.object(
Device, "async_create_device", AsyncMock(return_value=mock_device)
), patch.object(
ssdp,
"async_get_discovery_info_by_udn_st",
Mock(return_value=ssdp_discoveries[0]),
):
# Initialisation of component.
await async_setup_component(hass, "upnp", config)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_device.times_polled = 0 # Reset. mock_device = hass.data[DOMAIN][DOMAIN_DEVICES][TEST_UDN]
# Reset.
mock_device.times_polled = 0
# Forward time, ensure single poll after 30 (default) seconds. # Forward time, ensure single poll after 30 (default) seconds.
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31))

View File

@ -1,6 +1,7 @@
"""Test UPnP/IGD setup process.""" """Test UPnP/IGD setup process."""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock, patch import pytest
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.upnp.const import ( from homeassistant.components.upnp.const import (
@ -8,51 +9,37 @@ from homeassistant.components.upnp.const import (
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DOMAIN, DOMAIN,
) )
from homeassistant.components.upnp.device import Device from homeassistant.core import CoreState, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .mock_device import MockDevice from .common import TEST_DISCOVERY, TEST_ST, TEST_UDN
from .mock_ssdp_scanner import mock_ssdp_scanner # noqa: F401
from .mock_upnp_device import mock_upnp_device # noqa: F401
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device")
async def test_async_setup_entry_default(hass: HomeAssistant): async def test_async_setup_entry_default(hass: HomeAssistant):
"""Test async_setup_entry.""" """Test async_setup_entry."""
udn = "uuid:device_1"
location = "http://192.168.1.1/desc.xml"
mock_device = MockDevice(udn)
discovery = {
ssdp.ATTR_SSDP_LOCATION: location,
ssdp.ATTR_SSDP_ST: mock_device.device_type,
ssdp.ATTR_UPNP_UDN: mock_device.udn,
ssdp.ATTR_SSDP_USN: mock_device.usn,
}
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONFIG_ENTRY_UDN: mock_device.udn, CONFIG_ENTRY_UDN: TEST_UDN,
CONFIG_ENTRY_ST: mock_device.device_type, CONFIG_ENTRY_ST: TEST_ST,
}, },
) )
config = { # Initialisation of component, no device discovered.
# no upnp await async_setup_component(hass, DOMAIN, {})
}
async_create_device = AsyncMock(return_value=mock_device)
mock_get_discovery = Mock()
with patch.object(Device, "async_create_device", async_create_device), patch.object(
ssdp, "async_get_discovery_info_by_udn_st", mock_get_discovery
):
# initialisation of component, no device discovered
mock_get_discovery.return_value = None
await async_setup_component(hass, "upnp", config)
await hass.async_block_till_done() await hass.async_block_till_done()
# loading of config_entry, device discovered # Device is discovered.
mock_get_discovery.return_value = discovery ssdp_scanner: ssdp.Scanner = hass.data[ssdp.DOMAIN]
ssdp_scanner.cache[(TEST_UDN, TEST_ST)] = TEST_DISCOVERY
# Speed up callback in ssdp.async_register_callback.
hass.state = CoreState.not_running
# Load config_entry.
entry.add_to_hass(hass) entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id) is True assert await hass.config_entries.async_setup(entry.entry_id) is True
# ensure device is stored/used
async_create_device.assert_called_with(hass, discovery[ssdp.ATTR_SSDP_LOCATION])