Make home assistant discoverable via UPnP/SSDP (#79820)

pull/80386/head
Steven Looman 2022-10-15 20:00:46 +02:00 committed by GitHub
parent d12cbab6c4
commit 731f618028
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 290 additions and 34 deletions

View File

@ -3,7 +3,7 @@
"name": "DLNA Digital Media Renderer",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"requirements": ["async-upnp-client==0.31.2"],
"requirements": ["async-upnp-client==0.32.0"],
"dependencies": ["ssdp"],
"after_dependencies": ["media_source"],
"ssdp": [

View File

@ -3,7 +3,7 @@
"name": "DLNA Digital Media Server",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"requirements": ["async-upnp-client==0.31.2"],
"requirements": ["async-upnp-client==0.32.0"],
"dependencies": ["ssdp"],
"after_dependencies": ["media_source"],
"ssdp": [

View File

@ -7,7 +7,7 @@
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.5.0",
"wakeonlan==2.1.0",
"async-upnp-client==0.31.2"
"async-upnp-client==0.32.0"
],
"ssdp": [
{

View File

@ -8,27 +8,54 @@ from datetime import timedelta
from enum import Enum
from ipaddress import IPv4Address, IPv6Address
import logging
import socket
from typing import Any
from urllib.parse import urljoin
import xml.etree.ElementTree as ET
from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.const import AddressTupleVXType, DeviceOrServiceType, SsdpSource
from async_upnp_client.const import (
AddressTupleVXType,
DeviceIcon,
DeviceInfo,
DeviceOrServiceType,
SsdpSource,
)
from async_upnp_client.description_cache import DescriptionCache
from async_upnp_client.server import (
SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE,
SSDP_SEARCH_RESPONDER_OPTIONS,
UpnpServer,
UpnpServerDevice,
UpnpServerService,
)
from async_upnp_client.ssdp import SSDP_PORT, determine_source_target, is_ipv4_address
from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener
from async_upnp_client.utils import CaseInsensitiveDict
from homeassistant import config_entries
from homeassistant.components import network
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, MATCH_ALL
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
MATCH_ALL,
__version__ as current_version,
)
from homeassistant.core import HomeAssistant, callback as core_callback
from homeassistant.data_entry_flow import BaseServiceInfo
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from homeassistant.helpers.network import get_url
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_ssdp, bind_hass
DOMAIN = "ssdp"
SSDP_SCANNER = "scanner"
UPNP_SERVER = "server"
UPNP_SERVER_MIN_PORT = 40000
UPNP_SERVER_MAX_PORT = 40100
SCAN_INTERVAL = timedelta(minutes=2)
IPV4_BROADCAST = IPv4Address("255.255.255.255")
@ -133,7 +160,7 @@ async def async_register_callback(
Returns a callback that can be used to cancel the registration.
"""
scanner: Scanner = hass.data[DOMAIN]
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_register_callback(callback, match_dict)
@ -142,7 +169,7 @@ async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name
hass: HomeAssistant, udn: str, st: str
) -> SsdpServiceInfo | None:
"""Fetch the discovery info cache."""
scanner: Scanner = hass.data[DOMAIN]
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_udn_st(udn, st)
@ -151,7 +178,7 @@ async def async_get_discovery_info_by_st( # pylint: disable=invalid-name
hass: HomeAssistant, st: str
) -> list[SsdpServiceInfo]:
"""Fetch all the entries matching the st."""
scanner: Scanner = hass.data[DOMAIN]
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_st(st)
@ -160,19 +187,34 @@ async def async_get_discovery_info_by_udn(
hass: HomeAssistant, udn: str
) -> list[SsdpServiceInfo]:
"""Fetch all the entries matching the udn."""
scanner: Scanner = hass.data[DOMAIN]
scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER]
return await scanner.async_get_discovery_info_by_udn(udn)
async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]:
"""Build the list of ssdp sources."""
return {
source_ip
for source_ip in await network.async_get_enabled_source_ips(hass)
if not source_ip.is_loopback and not source_ip.is_global
}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SSDP integration."""
integration_matchers = IntegrationMatchers()
integration_matchers.async_setup(await async_get_ssdp(hass))
scanner = hass.data[DOMAIN] = Scanner(hass, integration_matchers)
scanner = Scanner(hass, integration_matchers)
server = Server(hass)
hass.data[DOMAIN] = {
SSDP_SCANNER: scanner,
UPNP_SERVER: server,
}
asyncio.create_task(scanner.async_start())
hass.create_task(scanner.async_start())
hass.create_task(server.async_start())
return True
@ -322,14 +364,6 @@ class Scanner:
return_exceptions=True,
)
async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]:
"""Build the list of ssdp sources."""
return {
source_ip
for source_ip in await network.async_get_enabled_source_ips(self.hass)
if not source_ip.is_loopback and not source_ip.is_global
}
async def async_scan(self, *_: Any) -> None:
"""Scan for new entries using ssdp listeners."""
await self.async_scan_multicast()
@ -369,7 +403,7 @@ class Scanner:
"""Start the SSDP Listeners."""
# Devices are shared between all sources.
device_tracker = SsdpDeviceTracker()
for source_ip in await self._async_build_source_set():
for source_ip in await async_build_source_set(self.hass):
source_ip_str = str(source_ip)
if source_ip.version == 6:
source_tuple: AddressTupleVXType = (
@ -559,3 +593,171 @@ def _udn_from_usn(usn: str | None) -> str | None:
if usn.startswith("uuid:"):
return usn.split("::")[0]
return None
class HassUpnpServiceDevice(UpnpServerDevice):
"""Hass Device."""
DEVICE_DEFINITION = DeviceInfo(
device_type="urn:home-assistant.io:device:HomeAssistant:1",
friendly_name="filled_later_on",
manufacturer="Home Assistant",
manufacturer_url="https://www.home-assistant.io",
model_description=None,
model_name="filled_later_on",
model_number=current_version,
model_url="https://www.home-assistant.io",
serial_number="filled_later_on",
udn="filled_later_on",
upc=None,
presentation_url="https://my.home-assistant.io/",
url="/device.xml",
icons=[
DeviceIcon(
mimetype="image/png",
width=1024,
height=1024,
depth=24,
url="/static/icons/favicon-1024x1024.png",
),
DeviceIcon(
mimetype="image/png",
width=512,
height=512,
depth=24,
url="/static/icons/favicon-512x512.png",
),
DeviceIcon(
mimetype="image/png",
width=384,
height=384,
depth=24,
url="/static/icons/favicon-384x384.png",
),
DeviceIcon(
mimetype="image/png",
width=192,
height=192,
depth=24,
url="/static/icons/favicon-192x192.png",
),
],
xml=ET.Element("server_device"),
)
EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = []
SERVICES: list[type[UpnpServerService]] = []
async def _async_find_next_available_port(source: AddressTupleVXType) -> int:
"""Get a free TCP port."""
family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6
test_socket = socket.socket(family, socket.SOCK_STREAM)
test_socket.setblocking(False)
test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT):
try:
test_socket.bind(source)
return port
except OSError:
if port == UPNP_SERVER_MAX_PORT:
raise
raise RuntimeError("unreachable")
class Server:
"""Class to be visible via SSDP searching and advertisements."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize class."""
self.hass = hass
self._upnp_servers: list[UpnpServer] = []
async def async_start(self) -> None:
"""Start the server."""
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
await self._async_start_upnp_servers()
async def _async_get_instance_udn(self) -> str:
"""Get Unique Device Name for this instance."""
instance_id = await async_get_instance_id(self.hass)
return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper()
async def _async_start_upnp_servers(self) -> None:
"""Start the UPnP/SSDP servers."""
# Update UDN with our instance UDN.
udn = await self._async_get_instance_udn()
system_info = await async_get_system_info(self.hass)
model_name = system_info["installation_type"]
presentation_url = get_url(self.hass)
serial_number = await async_get_instance_id(self.hass)
HassUpnpServiceDevice.DEVICE_DEFINITION = (
HassUpnpServiceDevice.DEVICE_DEFINITION._replace(
udn=udn,
friendly_name=f"{self.hass.config.location_name} (Home Assistant)",
model_name=model_name,
presentation_url=presentation_url,
serial_number=serial_number,
)
)
# Update icon URLs.
for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons):
new_url = urljoin(presentation_url, icon.url)
HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace(
url=new_url
)
# Start a server on all source IPs.
for source_ip in await async_build_source_set(self.hass):
source_ip_str = str(source_ip)
if source_ip.version == 6:
source_tuple: AddressTupleVXType = (
source_ip_str,
0,
0,
int(getattr(source_ip, "scope_id")),
)
else:
source_tuple = (source_ip_str, 0)
source, target = determine_source_target(source_tuple)
http_port = await _async_find_next_available_port(source)
_LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port)
self._upnp_servers.append(
UpnpServer(
source=source,
target=target,
http_port=http_port,
server_device=HassUpnpServiceDevice,
options={
SSDP_SEARCH_RESPONDER_OPTIONS: {
SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE: True
}
},
)
)
results = await asyncio.gather(
*(upnp_server.async_start() for upnp_server in self._upnp_servers),
return_exceptions=True,
)
failed_servers = []
for idx, result in enumerate(results):
if isinstance(result, Exception):
_LOGGER.debug(
"Failed to setup server for %s: %s",
self._upnp_servers[idx].source,
result,
)
failed_servers.append(self._upnp_servers[idx])
for server in failed_servers:
self._upnp_servers.remove(server)
async def async_stop(self, *_: Any) -> None:
"""Stop the server."""
await self._async_stop_upnp_servers()
async def _async_stop_upnp_servers(self) -> None:
"""Stop UPnP/SSDP servers."""
for server in self._upnp_servers:
await server.async_stop()

View File

@ -2,7 +2,7 @@
"domain": "ssdp",
"name": "Simple Service Discovery Protocol (SSDP)",
"documentation": "https://www.home-assistant.io/integrations/ssdp",
"requirements": ["async-upnp-client==0.31.2"],
"requirements": ["async-upnp-client==0.32.0"],
"dependencies": ["network"],
"after_dependencies": ["zeroconf"],
"codeowners": [],

View File

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

View File

@ -2,7 +2,7 @@
"domain": "yeelight",
"name": "Yeelight",
"documentation": "https://www.home-assistant.io/integrations/yeelight",
"requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.2"],
"requirements": ["yeelight==0.7.10", "async-upnp-client==0.32.0"],
"codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"],
"config_flow": true,
"dependencies": ["network"],

View File

@ -76,7 +76,7 @@ class YeelightScanner:
self._listeners.append(
SsdpSearchListener(
async_callback=self._async_process_entry,
service_type=SSDP_ST,
search_target=SSDP_ST,
target=SSDP_TARGET,
source=source,
async_connect_callback=_wrap_async_connected_idx(idx),

View File

@ -4,7 +4,7 @@ aiodiscover==1.4.13
aiohttp==3.8.1
aiohttp_cors==0.7.0
astral==2.2
async-upnp-client==0.31.2
async-upnp-client==0.32.0
async_timeout==4.0.2
atomicwrites-homeassistant==1.4.1
attrs==21.2.0

View File

@ -353,7 +353,7 @@ asterisk_mbox==0.5.0
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.31.2
async-upnp-client==0.32.0
# homeassistant.components.supla
asyncpysupla==0.0.5

View File

@ -307,7 +307,7 @@ arcam-fmj==0.12.0
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.31.2
async-upnp-client==0.32.0
# homeassistant.components.sleepiq
asyncsleepiq==1.2.3

View File

@ -12,7 +12,9 @@ from tests.components.blueprint.conftest import stub_blueprint_populate # noqa:
@pytest.fixture(autouse=True)
def mock_ssdp():
"""Mock ssdp."""
with patch("homeassistant.components.ssdp.Scanner.async_scan"):
with patch("homeassistant.components.ssdp.Scanner.async_scan"), patch(
"homeassistant.components.ssdp.Server.async_start"
), patch("homeassistant.components.ssdp.Server.async_stop"):
yield

View File

@ -116,13 +116,20 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]:
@pytest.fixture(autouse=True)
def ssdp_scanner_mock() -> Iterable[Mock]:
"""Mock the SSDP module."""
"""Mock the SSDP Scanner."""
with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner:
reg_callback = mock_scanner.return_value.async_register_callback
reg_callback.return_value = Mock(return_value=None)
yield mock_scanner.return_value
@pytest.fixture(autouse=True)
def ssdp_server_mock() -> Iterable[Mock]:
"""Mock the SSDP Server."""
with patch("homeassistant.components.ssdp.Server", autospec=True):
yield
@pytest.fixture(autouse=True)
def async_get_local_ip_mock() -> Iterable[Mock]:
"""Mock the async_get_local_ip utility function to prevent network access."""

View File

@ -129,13 +129,20 @@ def dms_device_mock(upnp_factory_mock: Mock) -> Iterable[Mock]:
@pytest.fixture(autouse=True)
def ssdp_scanner_mock() -> Iterable[Mock]:
"""Mock the SSDP module."""
"""Mock the SSDP Scanner."""
with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner:
reg_callback = mock_scanner.return_value.async_register_callback
reg_callback.return_value = Mock(return_value=None)
yield mock_scanner.return_value
@pytest.fixture(autouse=True)
def ssdp_server_mock() -> Iterable[Mock]:
"""Mock the SSDP Server."""
with patch("homeassistant.components.ssdp.Server", autospec=True):
yield
@pytest.fixture
async def device_source_mock(
hass: HomeAssistant,

View File

@ -32,6 +32,10 @@ async def silent_ssdp_scanner(hass):
"homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"
), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch(
"homeassistant.components.ssdp.Scanner.async_scan"
), patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers"
), patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers"
):
yield

View File

@ -135,6 +135,10 @@ async def silent_ssdp_scanner(hass):
"homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"
), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch(
"homeassistant.components.ssdp.Scanner.async_scan"
), patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers"
), patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers"
):
yield

View File

@ -1,6 +1,7 @@
"""Configuration for SSDP tests."""
from unittest.mock import AsyncMock, patch
from async_upnp_client.server import UpnpServer
from async_upnp_client.ssdp_listener import SsdpListener
import pytest
@ -16,6 +17,15 @@ async def silent_ssdp_listener():
yield SsdpListener
@pytest.fixture(autouse=True)
async def disabled_upnp_server():
"""Disable UPnpServer."""
with patch("homeassistant.components.ssdp.UpnpServer.async_start"), patch(
"homeassistant.components.ssdp.UpnpServer.async_stop"
), patch("homeassistant.components.ssdp._async_find_next_available_port"):
yield UpnpServer
@pytest.fixture
def mock_flow_init(hass):
"""Mock hass.config_entries.flow.async_init."""

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from ipaddress import IPv4Address
from unittest.mock import ANY, AsyncMock, patch
from async_upnp_client.server import UpnpServer
from async_upnp_client.ssdp import udn_from_headers
from async_upnp_client.ssdp_listener import SsdpListener
from async_upnp_client.utils import CaseInsensitiveDict
@ -34,7 +35,7 @@ async def init_ssdp_component(hass: homeassistant) -> SsdpListener:
"""Initialize ssdp component and get SsdpListener."""
await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}})
await hass.async_block_till_done()
return hass.data[ssdp.DOMAIN]._ssdp_listeners[0]
return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0]
@patch(
@ -407,7 +408,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st(
@patch(
"homeassistant.components.ssdp.Scanner._async_build_source_set",
"homeassistant.components.ssdp.async_build_source_set",
return_value={IPv4Address("192.168.1.1")},
)
@pytest.mark.usefixtures("mock_get_source_ip")
@ -668,7 +669,7 @@ async def test_async_detect_interfaces_setting_empty_route(
"""Test without default interface config and the route returns nothing."""
await init_ssdp_component(hass)
ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners
ssdp_listeners = hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners
sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners}
assert sources == {("2001:db8::%1", 0, 0, 1), ("192.168.1.5", 0)}
@ -698,14 +699,25 @@ async def test_bind_failure_skips_adapter(
raise OSError
SsdpListener.async_start = _async_start
UpnpServer.async_start = _async_start
await init_ssdp_component(hass)
assert "Failed to setup listener for" in caplog.text
ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners
ssdp_listeners: list[SsdpListener] = hass.data[ssdp.DOMAIN][
ssdp.SSDP_SCANNER
]._ssdp_listeners
sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners}
assert sources == {("192.168.1.5", 0)} # Note no SsdpListener for IPv6 address.
assert "Failed to setup server for" in caplog.text
upnp_servers: list[UpnpServer] = hass.data[ssdp.DOMAIN][
ssdp.UPNP_SERVER
]._upnp_servers
sources = {upnp_server.source for upnp_server in upnp_servers}
assert sources == {("192.168.1.5", 0)} # Note no UpnpServer for IPv6 address.
@pytest.mark.usefixtures("mock_get_source_ip")
@patch(

View File

@ -122,6 +122,10 @@ async def silent_ssdp_scanner(hass):
"homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"
), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch(
"homeassistant.components.ssdp.Scanner.async_scan"
), patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers"
), patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers"
):
yield

View File

@ -21,6 +21,10 @@ async def silent_ssdp_scanner(hass):
"homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"
), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch(
"homeassistant.components.ssdp.Scanner.async_scan"
), patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers"
), patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers"
):
yield