Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for DLNA DMR."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 from functools import partial
7 from ipaddress import IPv6Address, ip_address
8 import logging
9 from pprint import pformat
10 from typing import TYPE_CHECKING, Any, cast
11 from urllib.parse import urlparse
12 
13 from async_upnp_client.client import UpnpError
14 from async_upnp_client.profiles.dlna import DmrDevice
15 from async_upnp_client.profiles.profile import find_device_of_type
16 from getmac import get_mac_address
17 import voluptuous as vol
18 
19 from homeassistant.components import ssdp
20 from homeassistant.config_entries import (
21  ConfigEntry,
22  ConfigFlow,
23  ConfigFlowResult,
24  OptionsFlow,
25 )
26 from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL
27 from homeassistant.core import HomeAssistant, callback
28 from homeassistant.exceptions import IntegrationError
29 from homeassistant.helpers import config_validation as cv, device_registry as dr
30 from homeassistant.helpers.typing import VolDictType
31 
32 from .const import (
33  CONF_BROWSE_UNFILTERED,
34  CONF_CALLBACK_URL_OVERRIDE,
35  CONF_LISTEN_PORT,
36  CONF_POLL_AVAILABILITY,
37  DEFAULT_NAME,
38  DOMAIN,
39 )
40 from .data import get_domain_data
41 
42 LOGGER = logging.getLogger(__name__)
43 
44 type FlowInput = Mapping[str, Any] | None
45 
46 
48  """Error occurred when trying to connect to a device."""
49 
50 
51 class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN):
52  """Handle a DLNA DMR config flow.
53 
54  The Unique Device Name (UDN) of the DMR device is used as the unique_id for
55  config entries and for entities. This UDN may differ from the root UDN if
56  the DMR is an embedded device.
57  """
58 
59  VERSION = 1
60 
61  def __init__(self) -> None:
62  """Initialize flow."""
63  self._discoveries_discoveries: dict[str, ssdp.SsdpServiceInfo] = {}
64  self._location_location: str | None = None
65  self._udn_udn: str | None = None
66  self._device_type_device_type: str | None = None
67  self._name_name: str | None = None
68  self._mac_mac: str | None = None
69  self._options: dict[str, Any] = {}
70 
71  @staticmethod
72  @callback
74  config_entry: ConfigEntry,
75  ) -> OptionsFlow:
76  """Define the config flow to handle options."""
78 
79  async def async_step_user(self, user_input: FlowInput = None) -> ConfigFlowResult:
80  """Handle a flow initialized by the user.
81 
82  Let user choose from a list of found and unconfigured devices or to
83  enter an URL manually.
84  """
85  LOGGER.debug("async_step_user: user_input: %s", user_input)
86 
87  if user_input is not None:
88  if not (host := user_input.get(CONF_HOST)):
89  # No device chosen, user might want to directly enter an URL
90  return await self.async_step_manualasync_step_manual()
91  # User has chosen a device, ask for confirmation
92  discovery = self._discoveries_discoveries[host]
93  await self._async_set_info_from_discovery_async_set_info_from_discovery(discovery)
94  return self._create_entry_create_entry()
95 
96  if not (discoveries := await self._async_get_discoveries_async_get_discoveries()):
97  # Nothing found, maybe the user knows an URL to try
98  return await self.async_step_manualasync_step_manual()
99 
100  self._discoveries_discoveries = {
101  discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
102  or cast(str, urlparse(discovery.ssdp_location).hostname): discovery
103  for discovery in discoveries
104  }
105 
106  data_schema = vol.Schema(
107  {vol.Optional(CONF_HOST): vol.In(self._discoveries_discoveries.keys())}
108  )
109  return self.async_show_formasync_show_formasync_show_form(step_id="user", data_schema=data_schema)
110 
111  async def async_step_manual(self, user_input: FlowInput = None) -> ConfigFlowResult:
112  """Manual URL entry by the user."""
113  LOGGER.debug("async_step_manual: user_input: %s", user_input)
114 
115  # Device setup manually, assume we don't get SSDP broadcast notifications
116  self._options[CONF_POLL_AVAILABILITY] = True
117 
118  errors = {}
119  if user_input is not None:
120  self._location_location = user_input[CONF_URL]
121  try:
122  await self._async_connect_async_connect()
123  except ConnectError as err:
124  errors["base"] = err.args[0]
125  else:
126  return self._create_entry_create_entry()
127 
128  data_schema = vol.Schema({CONF_URL: str})
129  return self.async_show_formasync_show_formasync_show_form(
130  step_id="manual", data_schema=data_schema, errors=errors
131  )
132 
133  async def async_step_ssdp(
134  self, discovery_info: ssdp.SsdpServiceInfo
135  ) -> ConfigFlowResult:
136  """Handle a flow initialized by SSDP discovery."""
137  if LOGGER.isEnabledFor(logging.DEBUG):
138  LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info))
139 
140  await self._async_set_info_from_discovery_async_set_info_from_discovery(discovery_info)
141  if TYPE_CHECKING:
142  # _async_set_info_from_discovery unconditionally sets self._name
143  assert self._name_name is not None
144 
145  if _is_ignored_device(discovery_info):
146  return self.async_abortasync_abortasync_abort(reason="alternative_integration")
147 
148  # Abort if the device doesn't support all services required for a DmrDevice.
149  if not _is_dmr_device(discovery_info):
150  return self.async_abortasync_abortasync_abort(reason="not_dmr")
151 
152  # Abort if another config entry has the same location or MAC address, in
153  # case the device doesn't have a static and unique UDN (breaking the
154  # UPnP spec).
155  for entry in self._async_current_entries_async_current_entries(include_ignore=True):
156  if self._location_location == entry.data.get(CONF_URL):
157  return self.async_abortasync_abortasync_abort(reason="already_configured")
158  if self._mac_mac and self._mac_mac == entry.data.get(CONF_MAC):
159  return self.async_abortasync_abortasync_abort(reason="already_configured")
160 
161  self.context["title_placeholders"] = {"name": self._name_name}
162 
163  return await self.async_step_confirmasync_step_confirm()
164 
165  async def async_step_ignore(
166  self, user_input: Mapping[str, Any]
167  ) -> ConfigFlowResult:
168  """Ignore this config flow, and add MAC address as secondary identifier.
169 
170  Not all DMR devices correctly implement the spec, so their UDN may
171  change between boots. Use the MAC address as a secondary identifier so
172  they can still be ignored in this case.
173  """
174  LOGGER.debug("async_step_ignore: user_input: %s", user_input)
175  self._udn_udn = user_input["unique_id"]
176  assert self._udn_udn
177  await self.async_set_unique_idasync_set_unique_id(self._udn_udn, raise_on_progress=False)
178 
179  # Try to get relevant info from SSDP discovery, but don't worry if it's
180  # not available - the data values will just be None in that case
181  for dev_type in DmrDevice.DEVICE_TYPES:
182  discovery = await ssdp.async_get_discovery_info_by_udn_st(
183  self.hass, self._udn_udn, dev_type
184  )
185  if discovery:
186  await self._async_set_info_from_discovery_async_set_info_from_discovery(
187  discovery, abort_if_configured=False
188  )
189  break
190 
191  return self.async_create_entryasync_create_entryasync_create_entry(
192  title=user_input["title"],
193  data={
194  CONF_URL: self._location_location,
195  CONF_DEVICE_ID: self._udn_udn,
196  CONF_TYPE: self._device_type_device_type,
197  CONF_MAC: self._mac_mac,
198  },
199  )
200 
202  self, user_input: FlowInput = None
203  ) -> ConfigFlowResult:
204  """Allow the user to confirm adding the device."""
205  LOGGER.debug("async_step_confirm: %s", user_input)
206 
207  if user_input is not None:
208  return self._create_entry_create_entry()
209 
210  self._set_confirm_only_set_confirm_only()
211  return self.async_show_formasync_show_formasync_show_form(step_id="confirm")
212 
213  async def _async_connect(self) -> None:
214  """Connect to a device to confirm it works and gather extra information.
215 
216  Updates this flow's unique ID to the device UDN if not already done.
217  Raises ConnectError if something goes wrong.
218  """
219  LOGGER.debug("_async_connect: location: %s", self._location_location)
220  assert self._location_location, "self._location has not been set before connect"
221 
222  domain_data = get_domain_data(self.hass)
223  try:
224  device = await domain_data.upnp_factory.async_create_device(self._location_location)
225  except UpnpError as err:
226  raise ConnectError("cannot_connect") from err
227 
228  if not DmrDevice.is_profile_device(device):
229  raise ConnectError("not_dmr")
230 
231  device = find_device_of_type(device, DmrDevice.DEVICE_TYPES)
232 
233  if not self._udn_udn:
234  self._udn_udn = device.udn
235  await self.async_set_unique_idasync_set_unique_id(self._udn_udn)
236 
237  # Abort if already configured, but update the last-known location
238  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
239  updates={CONF_URL: self._location_location}, reload_on_update=False
240  )
241 
242  if not self._device_type_device_type:
243  self._device_type_device_type = device.device_type
244 
245  if not self._name_name:
246  self._name_name = device.name
247 
248  if not self._mac_mac and (host := urlparse(self._location_location).hostname):
249  self._mac_mac = await _async_get_mac_address(self.hass, host)
250 
251  def _create_entry(self) -> ConfigFlowResult:
252  """Create a config entry, assuming all required information is now known."""
253  LOGGER.debug(
254  "_async_create_entry: location: %s, UDN: %s", self._location_location, self._udn_udn
255  )
256  assert self._location_location
257  assert self._udn_udn
258  assert self._device_type_device_type
259 
260  title = self._name_name or urlparse(self._location_location).hostname or DEFAULT_NAME
261  data = {
262  CONF_URL: self._location_location,
263  CONF_DEVICE_ID: self._udn_udn,
264  CONF_TYPE: self._device_type_device_type,
265  CONF_MAC: self._mac_mac,
266  }
267  return self.async_create_entryasync_create_entryasync_create_entry(title=title, data=data, options=self._options)
268 
270  self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True
271  ) -> None:
272  """Set information required for a config entry from the SSDP discovery."""
273  LOGGER.debug(
274  "_async_set_info_from_discovery: location: %s, UDN: %s",
275  discovery_info.ssdp_location,
276  discovery_info.ssdp_udn,
277  )
278 
279  if not self._location_location:
280  self._location_location = discovery_info.ssdp_location
281  assert isinstance(self._location_location, str)
282 
283  self._udn_udn = discovery_info.ssdp_udn
284  await self.async_set_unique_idasync_set_unique_id(self._udn_udn, raise_on_progress=abort_if_configured)
285 
286  self._device_type_device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st
287  self._name_name = (
288  discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
289  or urlparse(self._location_location).hostname
290  or DEFAULT_NAME
291  )
292 
293  if host := discovery_info.ssdp_headers.get("_host"):
294  self._mac_mac = await _async_get_mac_address(self.hass, host)
295 
296  if abort_if_configured:
297  # Abort if already configured, but update the last-known location
298  updates = {CONF_URL: self._location_location}
299  # Set the MAC address for older entries
300  if self._mac_mac:
301  updates[CONF_MAC] = self._mac_mac
302  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates=updates, reload_on_update=False)
303 
304  async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]:
305  """Get list of unconfigured DLNA devices discovered by SSDP."""
306  LOGGER.debug("_get_discoveries")
307 
308  # Get all compatible devices from ssdp's cache
309  discoveries: list[ssdp.SsdpServiceInfo] = []
310  for udn_st in DmrDevice.DEVICE_TYPES:
311  st_discoveries = await ssdp.async_get_discovery_info_by_st(
312  self.hass, udn_st
313  )
314  discoveries.extend(st_discoveries)
315 
316  # Filter out devices already configured
317  current_unique_ids = {
318  entry.unique_id
319  for entry in self._async_current_entries_async_current_entries(include_ignore=False)
320  }
321  return [disc for disc in discoveries if disc.ssdp_udn not in current_unique_ids]
322 
323 
325  """Handle a DLNA DMR options flow.
326 
327  Configures the single instance and updates the existing config entry.
328  """
329 
330  async def async_step_init(
331  self, user_input: dict[str, Any] | None = None
332  ) -> ConfigFlowResult:
333  """Manage the options."""
334  errors: dict[str, str] = {}
335  # Don't modify existing (read-only) options -- copy and update instead
336  options = dict(self.config_entryconfig_entryconfig_entry.options)
337 
338  if user_input is not None:
339  LOGGER.debug("user_input: %s", user_input)
340  listen_port = user_input.get(CONF_LISTEN_PORT) or None
341  callback_url_override = user_input.get(CONF_CALLBACK_URL_OVERRIDE) or None
342 
343  try:
344  # Cannot use cv.url validation in the schema itself so apply
345  # extra validation here
346  if callback_url_override:
347  cv.url(callback_url_override)
348  except vol.Invalid:
349  errors["base"] = "invalid_url"
350 
351  options[CONF_LISTEN_PORT] = listen_port
352  options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override
353  options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY]
354  options[CONF_BROWSE_UNFILTERED] = user_input[CONF_BROWSE_UNFILTERED]
355 
356  # Save if there's no errors, else fall through and show the form again
357  if not errors:
358  return self.async_create_entryasync_create_entry(title="", data=options)
359 
360  fields: VolDictType = {}
361 
362  def _add_with_suggestion(key: str, validator: Callable | type[bool]) -> None:
363  """Add a field to with a suggested value.
364 
365  For bools, use the existing value as default, or fallback to False.
366  """
367  if validator is bool:
368  fields[vol.Required(key, default=options.get(key, False))] = validator
369  elif (suggested_value := options.get(key)) is None:
370  fields[vol.Optional(key)] = validator
371  else:
372  fields[
373  vol.Optional(key, description={"suggested_value": suggested_value})
374  ] = validator
375 
376  # listen_port can be blank or 0 for "bind any free port"
377  _add_with_suggestion(CONF_LISTEN_PORT, cv.port)
378  _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str)
379  _add_with_suggestion(CONF_POLL_AVAILABILITY, bool)
380  _add_with_suggestion(CONF_BROWSE_UNFILTERED, bool)
381 
382  return self.async_show_formasync_show_form(
383  step_id="init",
384  data_schema=vol.Schema(fields),
385  errors=errors,
386  )
387 
388 
389 def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
390  """Return True if this device should be ignored for discovery.
391 
392  These devices are supported better by other integrations, so don't bug
393  the user about them. The user can add them if desired by via the user config
394  flow, which will list all discovered but unconfigured devices.
395  """
396  # Did the discovery trigger more than just this flow?
397  if len(discovery_info.x_homeassistant_matching_domains) > 1:
398  LOGGER.debug(
399  "Ignoring device supported by multiple integrations: %s",
400  discovery_info.x_homeassistant_matching_domains,
401  )
402  return True
403 
404  # Is the root device not a DMR?
405  if (
406  discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE)
407  not in DmrDevice.DEVICE_TYPES
408  ):
409  return True
410 
411  # Special cases for devices with other discovery methods (e.g. mDNS), or
412  # that advertise multiple unrelated (sent in separate discovery packets)
413  # UPnP devices.
414  manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower()
415  model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower()
416 
417  if manufacturer.startswith("xbmc") or model == "kodi":
418  # kodi
419  return True
420  if "philips" in manufacturer and "tv" in model:
421  # philips_js
422  # These TVs don't have a stable UDN, so also get discovered as a new
423  # device every time they are turned on.
424  return True
425  if manufacturer.startswith("samsung") and "tv" in model:
426  # samsungtv
427  return True
428  if manufacturer.startswith("lg") and "tv" in model:
429  # webostv
430  return True
431 
432  return False
433 
434 
435 def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
436  """Determine if discovery is a complete DLNA DMR device.
437 
438  Use the discovery_info instead of DmrDevice.is_profile_device to avoid
439  contacting the device again.
440  """
441  # Abort if the device doesn't support all services required for a DmrDevice.
442  discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST)
443  if not discovery_service_list:
444  return False
445 
446  services = discovery_service_list.get("service")
447  if not services:
448  discovery_service_ids: set[str] = set()
449  elif isinstance(services, list):
450  discovery_service_ids = {service.get("serviceId") for service in services}
451  else:
452  # Only one service defined (etree_to_dict failed to make a list)
453  discovery_service_ids = {services.get("serviceId")}
454 
455  if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids):
456  return False
457 
458  return True
459 
460 
461 async def _async_get_mac_address(hass: HomeAssistant, host: str) -> str | None:
462  """Get mac address from host name, IPv4 address, or IPv6 address."""
463  # Help mypy, which has trouble with the async_add_executor_job + partial call
464  mac_address: str | None
465  # getmac has trouble using IPv6 addresses as the "hostname" parameter so
466  # assume host is an IP address, then handle the case it's not.
467  try:
468  ip_addr = ip_address(host)
469  except ValueError:
470  mac_address = await hass.async_add_executor_job(
471  partial(get_mac_address, hostname=host)
472  )
473  else:
474  if ip_addr.version == 4:
475  mac_address = await hass.async_add_executor_job(
476  partial(get_mac_address, ip=host)
477  )
478  else:
479  # Drop scope_id from IPv6 address by converting via int
480  ip_addr = IPv6Address(int(ip_addr))
481  mac_address = await hass.async_add_executor_job(
482  partial(get_mac_address, ip6=str(ip_addr))
483  )
484 
485  if not mac_address:
486  return None
487 
488  return dr.format_mac(mac_address)
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:135
ConfigFlowResult async_step_user(self, FlowInput user_input=None)
Definition: config_flow.py:79
None _async_set_info_from_discovery(self, ssdp.SsdpServiceInfo discovery_info, bool abort_if_configured=True)
Definition: config_flow.py:271
ConfigFlowResult async_step_manual(self, FlowInput user_input=None)
Definition: config_flow.py:111
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:75
ConfigFlowResult async_step_ignore(self, Mapping[str, Any] user_input)
Definition: config_flow.py:167
ConfigFlowResult async_step_confirm(self, FlowInput user_input=None)
Definition: config_flow.py:203
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:332
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_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)
None _add_with_suggestion(dict[vol.Marker, type[str]] fields, str key, str suggested_value)
Definition: config_flow.py:210
str|None _async_get_mac_address(HomeAssistant hass, str host)
Definition: config_flow.py:461
bool _is_ignored_device(ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:389
bool _is_dmr_device(ssdp.SsdpServiceInfo discovery_info)
Definition: config_flow.py:435
DlnaDmrData get_domain_data(HomeAssistant hass)
Definition: data.py:120