-
-
Notifications
You must be signed in to change notification settings - Fork 37.7k
Improve UPnP configuration flow #34737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
2e4f892
d0275d2
27055c1
a39a3e1
00dcfdb
23c7fac
9b2d6ad
7cba662
a3ccbeb
bfb247a
9827096
99f4df4
0865367
cd8a9b0
0295c69
4db9b99
913d84d
ac0f067
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,172 @@ | ||
| """Config flow for UPNP.""" | ||
| from typing import Mapping, Optional | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.helpers import config_entry_flow | ||
| from homeassistant.components import ssdp | ||
|
|
||
| from .const import DOMAIN | ||
| from .const import ( # pylint: disable=unused-import | ||
| DOMAIN, | ||
| LOGGER as _LOGGER, | ||
| ) | ||
| from .device import Device | ||
| from urllib.parse import urlparse | ||
|
|
||
| config_entry_flow.register_discovery_flow( | ||
| DOMAIN, "UPnP/IGD", Device.async_discover, config_entries.CONN_CLASS_LOCAL_POLL | ||
| ) | ||
|
|
||
| LOCATION = "location" | ||
| NAME = "name" | ||
| ST = "st" | ||
| UDN = "udn" | ||
|
|
||
|
|
||
| class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a UPnP/IGD config flow.""" | ||
|
|
||
| VERSION = 1 | ||
| CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL | ||
|
|
||
| # Paths: | ||
| # - ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry() | ||
| # - user(None): scan --> user({...}) --> create_entry() | ||
| # - import(None) --> create_entry() | ||
|
|
||
| def __init__(self): | ||
| """Initialize the UPnP/IGD config flow.""" | ||
| self._data: Mapping = None | ||
| self._discoveries: Mapping = None | ||
|
|
||
| async def async_step_user(self, user_input=None): | ||
| """Handle a flow start.""" | ||
| _LOGGER.debug("async_step_user: user_input: %s", user_input) | ||
|
|
||
| if user_input is not None: | ||
| # Ensure wanted device was discovered. | ||
| matching_discoveries = [ | ||
| discovery | ||
| for discovery in self._discoveries | ||
| if discovery["unique_id"] == user_input["unique_id"] | ||
| ] | ||
|
StevenLooman marked this conversation as resolved.
|
||
| if not matching_discoveries: | ||
| errors = {"base": "no_devices_discovered"} | ||
| return self.async_show_form(step_id="user", errors=errors,) | ||
|
|
||
| # Title/name will be updated later on. | ||
| self._data = matching_discoveries[0] | ||
|
StevenLooman marked this conversation as resolved.
Outdated
|
||
| self._data[NAME] = urlparse(self._data[ssdp.ATTR_SSDP_LOCATION]).hostname | ||
| return self._async_create_entry_from_data() | ||
|
|
||
| # Discover devices. | ||
| self._discoveries = await Device.async_discover(self.hass) | ||
|
|
||
| # Filter discoveries which are already configured. | ||
| current_unique_ids = [ | ||
| entry.unique_id for entry in self._async_current_entries() | ||
| ] | ||
|
StevenLooman marked this conversation as resolved.
Outdated
|
||
| self._discoveries = [ | ||
| discovery | ||
| for discovery in self._discoveries | ||
| if discovery["unique_id"] not in current_unique_ids | ||
| ] | ||
|
|
||
| # Ensure anything to add. | ||
| if not self._discoveries: | ||
| errors = {"base": "no_devices_discovered"} | ||
| return self.async_show_form(step_id="user", errors=errors,) | ||
|
StevenLooman marked this conversation as resolved.
Outdated
|
||
|
|
||
| data_schema = vol.Schema( | ||
| { | ||
| vol.Required("unique_id"): vol.In( | ||
|
StevenLooman marked this conversation as resolved.
Outdated
|
||
| { | ||
| discovery["unique_id"]: urlparse( | ||
| discovery[ssdp.ATTR_SSDP_LOCATION] | ||
| ).hostname | ||
| for discovery in self._discoveries | ||
| } | ||
| ), | ||
| } | ||
| ) | ||
| return self.async_show_form(step_id="user", data_schema=data_schema,) | ||
|
|
||
| async def async_step_import(self, import_info: Optional[Mapping]): | ||
| """Import a new UPnP/IGD device as a config entry. | ||
|
|
||
| This flow is triggered by `async_setup`. | ||
| """ | ||
| _LOGGER.debug("async_step_import: import_info: %s", import_info) | ||
|
|
||
| if import_info is None: | ||
| # Landed here via configuration.yaml entry. | ||
| # Any device already added, then abort. | ||
| if self._async_current_entries(): | ||
| _LOGGER.debug("aborting, already configured") | ||
| return self.async_abort(reason="already_configured") | ||
|
|
||
| # Test if import_info isn't already configured. | ||
| if import_info is not None and any( | ||
| import_info["udn"] == entry.data["udn"] | ||
| and import_info["st"] == entry.data["st"] | ||
| for entry in self._async_current_entries() | ||
| ): | ||
| return self.async_abort(reason="already_configured") | ||
|
StevenLooman marked this conversation as resolved.
|
||
|
|
||
| # Discover devices. | ||
| self._discoveries = await Device.async_discover(self.hass) | ||
|
|
||
| # Ensure anything to add. If not, silently abort. | ||
| if not self._discoveries: | ||
| _LOGGER.info("No UPnP devices discovered, aborting.") | ||
| return self.async_abort(reason="no_devices_found") | ||
|
|
||
| # Create new config_entry. | ||
| self._data = self._discoveries[0] | ||
| return self._async_create_entry_from_data() | ||
|
|
||
| async def async_step_ssdp(self, discovery_info: Mapping): | ||
| """Handle a discovered UPnP/IGD device. | ||
|
|
||
| This flow is triggered by the SSDP component. It will check if the | ||
| host is already configured and delegate to the import step if not. | ||
| """ | ||
| _LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) | ||
|
|
||
| # Ensure not already configuring/configured. | ||
| udn = discovery_info[ssdp.ATTR_UPNP_UDN] | ||
| st = discovery_info[ssdp.ATTR_SSDP_ST] # pylint: disable=invalid-name | ||
| usn = f"{udn}::{st}" | ||
| await self.async_set_unique_id(usn) | ||
| self._abort_if_unique_id_configured(updates={UDN: udn, ST: st}) | ||
|
StevenLooman marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Store discovery. | ||
| name = discovery_info.get("friendlyName", "") | ||
| self._data = { | ||
| UDN: udn, | ||
| ST: st, | ||
| NAME: name, | ||
| } | ||
|
|
||
| # Ensure user recognizable. | ||
| location = discovery_info[ssdp.ATTR_SSDP_LOCATION] | ||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| self.context["title_placeholders"] = { | ||
| "name": name, | ||
| "host": urlparse(location).hostname, | ||
| } | ||
|
|
||
| return await self.async_step_ssdp_confirm() | ||
|
|
||
| async def async_step_ssdp_confirm(self, user_input=None): | ||
| """Confirm integration via SSDP.""" | ||
| _LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) | ||
| if user_input is None: | ||
| return self.async_show_form(step_id="ssdp_confirm") | ||
|
|
||
| return self._async_create_entry_from_data() | ||
|
|
||
| def _async_create_entry_from_data(self): | ||
| """Create an entry from own _data.""" | ||
| title = self._data["name"] | ||
| data = { | ||
| UDN: self._data["udn"], | ||
| ST: self._data["st"], | ||
| } | ||
| return self.async_create_entry(title=title, data=data) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,14 @@ | ||
| """Home Assistant representation of an UPnP/IGD.""" | ||
| import asyncio | ||
| from ipaddress import IPv4Address | ||
| from typing import Mapping | ||
| from typing import List, Mapping | ||
|
|
||
| import aiohttp | ||
| from async_upnp_client import UpnpError, UpnpFactory | ||
| from async_upnp_client.aiohttp import AiohttpSessionRequester | ||
| from async_upnp_client.profiles.igd import IgdDevice | ||
|
|
||
| from homeassistant.components import ssdp | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.typing import HomeAssistantType | ||
| import homeassistant.util.dt as dt_util | ||
|
|
@@ -33,7 +34,7 @@ def __init__(self, igd_device): | |
| self._mapped_ports = [] | ||
|
|
||
| @classmethod | ||
| async def async_discover(cls, hass: HomeAssistantType): | ||
| async def async_discover(cls, hass: HomeAssistantType) -> List[Mapping]: | ||
| """Discover UPnP/IGD devices.""" | ||
| _LOGGER.debug("Discovering UPnP/IGD devices") | ||
| local_ip = None | ||
|
|
@@ -47,8 +48,13 @@ async def async_discover(cls, hass: HomeAssistantType): | |
| # add extra info and store devices | ||
| devices = [] | ||
| for discovery_info in discovery_infos: | ||
| discovery_info["udn"] = discovery_info["_udn"] | ||
| discovery_info["ssdp_description"] = discovery_info["location"] | ||
| # Become more ssdp-component-discovery-like. | ||
| discovery_info[ssdp.ATTR_UPNP_UDN] = discovery_info["_udn"] | ||
| discovery_info[ssdp.ATTR_SSDP_ST] = discovery_info["st"] | ||
| discovery_info[ssdp.ATTR_SSDP_LOCATION] = discovery_info["location"] | ||
|
|
||
| unique_id = f"{discovery_info[ssdp.ATTR_UPNP_UDN]}::{discovery_info[ssdp.ATTR_SSDP_ST]}" | ||
| discovery_info["unique_id"] = unique_id | ||
| discovery_info["source"] = "async_upnp_client" | ||
| _LOGGER.debug("Discovered device: %s", discovery_info) | ||
|
|
||
|
|
@@ -57,15 +63,15 @@ async def async_discover(cls, hass: HomeAssistantType): | |
| return devices | ||
|
|
||
| @classmethod | ||
| async def async_create_device(cls, hass: HomeAssistantType, ssdp_description: str): | ||
| async def async_create_device(cls, hass: HomeAssistantType, ssdp_location: str): | ||
| """Create UPnP/IGD device.""" | ||
| # build async_upnp_client requester | ||
| session = async_get_clientsession(hass) | ||
| requester = AiohttpSessionRequester(session, True) | ||
|
|
||
| # create async_upnp_client device | ||
| factory = UpnpFactory(requester, disable_state_variable_validation=True) | ||
| upnp_device = await factory.async_create_device(ssdp_description) | ||
| upnp_device = await factory.async_create_device(ssdp_location) | ||
|
|
||
| igd_device = IgdDevice(upnp_device, None) | ||
|
|
||
|
|
@@ -96,6 +102,11 @@ def device_type(self) -> str: | |
| """Get the device type.""" | ||
| return self._igd_device.device_type | ||
|
|
||
| @property | ||
| def unique_id(self) -> str: | ||
| """Get the unique id.""" | ||
| return f"{self.udn}::{self.device_type}" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this include ST?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is confusing. The SSDP protocol provides a The UPnP XML description provides a Some criticism from my side: The SSDP component in hass returns the XML (in dict form), but entirely discards the search-information. Can be easily worked around, but requires a bit of additional work.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. Feel free to update the SSDP integration 👍 (although we use netdisco for ssdp scan, which has been deprecated…)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I couldn't find the required information in the current implementation. It probably requires a change to netdisco. What do you propose to do with SSDP, rewrite it to drop netdisco? Should it use its own search implementation or use a library for this? (
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, |
||
|
|
||
| def __str__(self) -> str: | ||
| """Get string representation.""" | ||
| return f"IGD Device: {self.name}/{self.udn}" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.