Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for UPNP."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from typing import Any, cast
7 from urllib.parse import urlparse
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import ssdp
12 from homeassistant.components.ssdp import SsdpServiceInfo
13 from homeassistant.config_entries import (
14  SOURCE_IGNORE,
15  ConfigEntry,
16  ConfigFlow,
17  ConfigFlowResult,
18  OptionsFlow,
19 )
20 from homeassistant.core import HomeAssistant, callback
21 
22 from .const import (
23  CONFIG_ENTRY_FORCE_POLL,
24  CONFIG_ENTRY_HOST,
25  CONFIG_ENTRY_LOCATION,
26  CONFIG_ENTRY_MAC_ADDRESS,
27  CONFIG_ENTRY_ORIGINAL_UDN,
28  CONFIG_ENTRY_ST,
29  CONFIG_ENTRY_UDN,
30  DEFAULT_CONFIG_ENTRY_FORCE_POLL,
31  DOMAIN,
32  DOMAIN_DISCOVERIES,
33  LOGGER,
34  ST_IGD_V1,
35  ST_IGD_V2,
36 )
37 from .device import async_get_mac_address_from_host, get_preferred_location
38 
39 
40 def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str:
41  """Extract user-friendly name from discovery."""
42  return cast(
43  str,
44  discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
45  or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)
46  or discovery_info.ssdp_headers.get("_host", ""),
47  )
48 
49 
50 def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool:
51  """Test if discovery is complete and usable."""
52  return bool(
53  discovery_info.ssdp_udn
54  and discovery_info.ssdp_st
55  and discovery_info.ssdp_all_locations
56  and discovery_info.ssdp_usn
57  )
58 
59 
61  hass: HomeAssistant,
62 ) -> list[ssdp.SsdpServiceInfo]:
63  """Discovery IGD devices."""
64  return await ssdp.async_get_discovery_info_by_st(
65  hass, ST_IGD_V1
66  ) + await ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2)
67 
68 
70  hass: HomeAssistant, discovery: SsdpServiceInfo
71 ) -> str | None:
72  """Get the mac address from a discovery."""
73  location = get_preferred_location(discovery.ssdp_all_locations)
74  host = urlparse(location).hostname
75  assert host is not None
76  return await async_get_mac_address_from_host(hass, host)
77 
78 
79 def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
80  """Test if discovery is a complete IGD device."""
81  root_device_info = discovery_info.upnp
82  return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2}
83 
84 
85 class UpnpFlowHandler(ConfigFlow, domain=DOMAIN):
86  """Handle a UPnP/IGD config flow."""
87 
88  VERSION = 1
89 
90  # Paths:
91  # 1: ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry()
92  # 2: user(None): scan --> user({...}) --> create_entry()
93 
94  @staticmethod
95  @callback
97  config_entry: ConfigEntry,
98  ) -> UpnpOptionsFlowHandler:
99  """Get the options flow for this handler."""
100  return UpnpOptionsFlowHandler()
101 
102  @property
103  def _discoveries(self) -> dict[str, SsdpServiceInfo]:
104  """Get current discoveries."""
105  domain_data: dict = self.hass.data.setdefault(DOMAIN, {})
106  return domain_data.setdefault(DOMAIN_DISCOVERIES, {})
107 
108  def _add_discovery(self, discovery: SsdpServiceInfo) -> None:
109  """Add a discovery."""
110  self._discoveries_discoveries[discovery.ssdp_usn] = discovery
111 
112  def _remove_discovery(self, usn: str) -> SsdpServiceInfo:
113  """Remove a discovery by its USN/unique_id."""
114  return self._discoveries_discoveries.pop(usn)
115 
116  async def async_step_user(
117  self, user_input: Mapping[str, Any] | None = None
118  ) -> ConfigFlowResult:
119  """Handle a flow start."""
120  LOGGER.debug("async_step_user: user_input: %s", user_input)
121 
122  if user_input is not None:
123  # Ensure wanted device was discovered.
124  assert self._discoveries_discoveries
125  discovery = next(
126  iter(
127  discovery
128  for discovery in self._discoveries_discoveries.values()
129  if discovery.ssdp_usn == user_input["unique_id"]
130  )
131  )
132  await self.async_set_unique_idasync_set_unique_id(discovery.ssdp_usn, raise_on_progress=False)
133  return await self._async_create_entry_from_discovery_async_create_entry_from_discovery(discovery)
134 
135  # Discover devices.
136  discoveries = await _async_discovered_igd_devices(self.hass)
137 
138  # Store discoveries which have not been configured.
139  current_unique_ids = {
140  entry.unique_id for entry in self._async_current_entries_async_current_entries()
141  }
142  for discovery in discoveries:
143  if (
144  _is_complete_discovery(discovery)
145  and _is_igd_device(discovery)
146  and discovery.ssdp_usn not in current_unique_ids
147  ):
148  self._add_discovery_add_discovery(discovery)
149 
150  # Ensure anything to add.
151  if not self._discoveries_discoveries:
152  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
153 
154  data_schema = vol.Schema(
155  {
156  vol.Required("unique_id"): vol.In(
157  {
158  discovery.ssdp_usn: _friendly_name_from_discovery(discovery)
159  for discovery in self._discoveries_discoveries.values()
160  }
161  ),
162  }
163  )
164  return self.async_show_formasync_show_formasync_show_form(
165  step_id="user",
166  data_schema=data_schema,
167  )
168 
169  async def async_step_ssdp(
170  self, discovery_info: ssdp.SsdpServiceInfo
171  ) -> ConfigFlowResult:
172  """Handle a discovered UPnP/IGD device.
173 
174  This flow is triggered by the SSDP component. It will check if the
175  host is already configured and delegate to the import step if not.
176  """
177  LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info)
178 
179  # Ensure complete discovery.
180  if not _is_complete_discovery(discovery_info):
181  LOGGER.debug("Incomplete discovery, ignoring")
182  return self.async_abortasync_abortasync_abort(reason="incomplete_discovery")
183 
184  # Ensure device is usable. Ideally we would use IgdDevice.is_profile_device,
185  # but that requires constructing the device completely.
186  if not _is_igd_device(discovery_info):
187  LOGGER.debug("Non IGD device, ignoring")
188  return self.async_abortasync_abortasync_abort(reason="non_igd_device")
189 
190  # Ensure not already configuring/configured.
191  unique_id = discovery_info.ssdp_usn
192  await self.async_set_unique_idasync_set_unique_id(unique_id)
193  mac_address = await _async_mac_address_from_discovery(self.hass, discovery_info)
194  host = discovery_info.ssdp_headers["_host"]
195  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
196  # Store mac address and other data for older entries.
197  # The location is stored in the config entry such that
198  # when the location changes, the entry is reloaded.
199  updates={
200  CONFIG_ENTRY_MAC_ADDRESS: mac_address,
201  CONFIG_ENTRY_LOCATION: get_preferred_location(
202  discovery_info.ssdp_all_locations
203  ),
204  CONFIG_ENTRY_HOST: host,
205  CONFIG_ENTRY_ST: discovery_info.ssdp_st,
206  },
207  )
208 
209  # Handle devices changing their UDN, only allow a single host.
210  for entry in self._async_current_entries_async_current_entries(include_ignore=True):
211  entry_mac_address = entry.data.get(CONFIG_ENTRY_MAC_ADDRESS)
212  entry_host = entry.data.get(CONFIG_ENTRY_HOST)
213  if entry_mac_address != mac_address and entry_host != host:
214  continue
215 
216  entry_st = entry.data.get(CONFIG_ENTRY_ST)
217  if discovery_info.ssdp_st != entry_st:
218  # Check ssdp_st to prevent swapping between IGDv1 and IGDv2.
219  continue
220 
221  if entry.source == SOURCE_IGNORE:
222  # Host was already ignored. Don't update ignored entries.
223  return self.async_abortasync_abortasync_abort(reason="discovery_ignored")
224 
225  LOGGER.debug("Updating entry: %s", entry.entry_id)
226  return self.async_update_reload_and_abortasync_update_reload_and_abort(
227  entry,
228  unique_id=unique_id,
229  data={**entry.data, CONFIG_ENTRY_UDN: discovery_info.ssdp_udn},
230  reason="config_entry_updated",
231  )
232 
233  # Store discovery.
234  self._add_discovery_add_discovery(discovery_info)
235 
236  # Ensure user recognizable.
237  self.context["title_placeholders"] = {
238  "name": _friendly_name_from_discovery(discovery_info),
239  }
240 
241  return await self.async_step_ssdp_confirmasync_step_ssdp_confirm()
242 
244  self, user_input: Mapping[str, Any] | None = None
245  ) -> ConfigFlowResult:
246  """Confirm integration via SSDP."""
247  LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input)
248  if user_input is None:
249  return self.async_show_formasync_show_formasync_show_form(step_id="ssdp_confirm")
250 
251  assert self.unique_idunique_id
252  discovery = self._remove_discovery_remove_discovery(self.unique_idunique_id)
253  return await self._async_create_entry_from_discovery_async_create_entry_from_discovery(discovery)
254 
255  async def async_step_ignore(self, user_input: dict[str, Any]) -> ConfigFlowResult:
256  """Ignore this config flow."""
257  usn = user_input["unique_id"]
258  discovery = self._remove_discovery_remove_discovery(usn)
259  mac_address = await _async_mac_address_from_discovery(self.hass, discovery)
260  data = {
261  CONFIG_ENTRY_UDN: discovery.ssdp_udn,
262  CONFIG_ENTRY_ST: discovery.ssdp_st,
263  CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn,
264  CONFIG_ENTRY_MAC_ADDRESS: mac_address,
265  CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"],
266  CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations),
267  }
268  options = {
269  CONFIG_ENTRY_FORCE_POLL: False,
270  }
271 
272  await self.async_set_unique_idasync_set_unique_id(user_input["unique_id"], raise_on_progress=False)
273  return self.async_create_entryasync_create_entryasync_create_entry(
274  title=user_input["title"], data=data, options=options
275  )
276 
278  self,
279  discovery: SsdpServiceInfo,
280  ) -> ConfigFlowResult:
281  """Create an entry from discovery."""
282  LOGGER.debug(
283  "_async_create_entry_from_discovery: discovery: %s",
284  discovery,
285  )
286 
287  title = _friendly_name_from_discovery(discovery)
288  mac_address = await _async_mac_address_from_discovery(self.hass, discovery)
289  data = {
290  CONFIG_ENTRY_UDN: discovery.ssdp_udn,
291  CONFIG_ENTRY_ST: discovery.ssdp_st,
292  CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn,
293  CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations),
294  CONFIG_ENTRY_MAC_ADDRESS: mac_address,
295  CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"],
296  }
297  options = {
298  CONFIG_ENTRY_FORCE_POLL: False,
299  }
300  return self.async_create_entryasync_create_entryasync_create_entry(title=title, data=data, options=options)
301 
302 
304  """Handle an options flow."""
305 
306  async def async_step_init(
307  self, user_input: dict[str, Any] | None = None
308  ) -> ConfigFlowResult:
309  """Handle options flow."""
310  if user_input is not None:
311  return self.async_create_entryasync_create_entry(title="", data=user_input)
312 
313  data_schema = vol.Schema(
314  {
315  vol.Optional(
316  CONFIG_ENTRY_FORCE_POLL,
317  default=self.config_entryconfig_entryconfig_entry.options.get(
318  CONFIG_ENTRY_FORCE_POLL, DEFAULT_CONFIG_ENTRY_FORCE_POLL
319  ),
320  ): bool,
321  }
322  )
323  return self.async_show_formasync_show_form(step_id="init", data_schema=data_schema)
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:171
ConfigFlowResult async_step_user(self, Mapping[str, Any]|None user_input=None)
Definition: config_flow.py:118
None _add_discovery(self, SsdpServiceInfo discovery)
Definition: config_flow.py:108
ConfigFlowResult async_step_ssdp_confirm(self, Mapping[str, Any]|None user_input=None)
Definition: config_flow.py:245
ConfigFlowResult async_step_ignore(self, dict[str, Any] user_input)
Definition: config_flow.py:255
UpnpOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:98
ConfigFlowResult _async_create_entry_from_discovery(self, SsdpServiceInfo discovery)
Definition: config_flow.py:280
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:308
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
list[ssdp.SsdpServiceInfo] _async_discovered_igd_devices(HomeAssistant hass)
Definition: config_flow.py:62
str _friendly_name_from_discovery(ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:40
str|None _async_mac_address_from_discovery(HomeAssistant hass, SsdpServiceInfo discovery)
Definition: config_flow.py:71
bool _is_complete_discovery(ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:50
bool _is_igd_device(ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:79
str get_preferred_location(set[str] locations)
Definition: device.py:56
str|None async_get_mac_address_from_host(HomeAssistant hass, str host)
Definition: device.py:72