1 """Config flow for DLNA DMR."""
3 from __future__
import annotations
5 from collections.abc
import Callable, Mapping
6 from functools
import partial
7 from ipaddress
import IPv6Address, ip_address
9 from pprint
import pformat
10 from typing
import TYPE_CHECKING, Any, cast
11 from urllib.parse
import urlparse
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
33 CONF_BROWSE_UNFILTERED,
34 CONF_CALLBACK_URL_OVERRIDE,
36 CONF_POLL_AVAILABILITY,
40 from .data
import get_domain_data
42 LOGGER = logging.getLogger(__name__)
44 type FlowInput = Mapping[str, Any] |
None
48 """Error occurred when trying to connect to a device."""
52 """Handle a DLNA DMR config flow.
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.
62 """Initialize flow."""
64 self.
_location_location: str |
None =
None
65 self.
_udn_udn: str |
None =
None
67 self.
_name_name: str |
None =
None
68 self.
_mac_mac: str |
None =
None
69 self._options: dict[str, Any] = {}
74 config_entry: ConfigEntry,
76 """Define the config flow to handle options."""
79 async
def async_step_user(self, user_input: FlowInput =
None) -> ConfigFlowResult:
80 """Handle a flow initialized by the user.
82 Let user choose from a list of found and unconfigured devices or to
83 enter an URL manually.
85 LOGGER.debug(
"async_step_user: user_input: %s", user_input)
87 if user_input
is not None:
88 if not (host := user_input.get(CONF_HOST)):
101 discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
102 or cast(str, urlparse(discovery.ssdp_location).hostname): discovery
103 for discovery
in discoveries
106 data_schema = vol.Schema(
107 {vol.Optional(CONF_HOST): vol.In(self.
_discoveries_discoveries.keys())}
112 """Manual URL entry by the user."""
113 LOGGER.debug(
"async_step_manual: user_input: %s", user_input)
116 self._options[CONF_POLL_AVAILABILITY] =
True
119 if user_input
is not None:
123 except ConnectError
as err:
124 errors[
"base"] = err.args[0]
128 data_schema = vol.Schema({CONF_URL: str})
130 step_id=
"manual", data_schema=data_schema, errors=errors
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))
143 assert self.
_name_name
is not None
156 if self.
_location_location == entry.data.get(CONF_URL):
158 if self.
_mac_mac
and self.
_mac_mac == entry.data.get(CONF_MAC):
161 self.context[
"title_placeholders"] = {
"name": self.
_name_name}
166 self, user_input: Mapping[str, Any]
167 ) -> ConfigFlowResult:
168 """Ignore this config flow, and add MAC address as secondary identifier.
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.
174 LOGGER.debug(
"async_step_ignore: user_input: %s", user_input)
175 self.
_udn_udn = user_input[
"unique_id"]
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
187 discovery, abort_if_configured=
False
192 title=user_input[
"title"],
195 CONF_DEVICE_ID: self.
_udn_udn,
197 CONF_MAC: self.
_mac_mac,
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)
207 if user_input
is not None:
214 """Connect to a device to confirm it works and gather extra information.
216 Updates this flow's unique ID to the device UDN if not already done.
217 Raises ConnectError if something goes wrong.
219 LOGGER.debug(
"_async_connect: location: %s", self.
_location_location)
220 assert self.
_location_location,
"self._location has not been set before connect"
224 device = await domain_data.upnp_factory.async_create_device(self.
_location_location)
225 except UpnpError
as err:
228 if not DmrDevice.is_profile_device(device):
231 device = find_device_of_type(device, DmrDevice.DEVICE_TYPES)
233 if not self.
_udn_udn:
234 self.
_udn_udn = device.udn
239 updates={CONF_URL: self.
_location_location}, reload_on_update=
False
245 if not self.
_name_name:
248 if not self.
_mac_mac
and (host := urlparse(self.
_location_location).hostname):
252 """Create a config entry, assuming all required information is now known."""
254 "_async_create_entry: location: %s, UDN: %s", self.
_location_location, self.
_udn_udn
260 title = self.
_name_name
or urlparse(self.
_location_location).hostname
or DEFAULT_NAME
263 CONF_DEVICE_ID: self.
_udn_udn,
265 CONF_MAC: self.
_mac_mac,
270 self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool =
True
272 """Set information required for a config entry from the SSDP discovery."""
274 "_async_set_info_from_discovery: location: %s, UDN: %s",
275 discovery_info.ssdp_location,
276 discovery_info.ssdp_udn,
280 self.
_location_location = discovery_info.ssdp_location
281 assert isinstance(self.
_location_location, str)
283 self.
_udn_udn = discovery_info.ssdp_udn
286 self.
_device_type_device_type = discovery_info.ssdp_nt
or discovery_info.ssdp_st
288 discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
289 or urlparse(self.
_location_location).hostname
293 if host := discovery_info.ssdp_headers.get(
"_host"):
296 if abort_if_configured:
298 updates = {CONF_URL: self.
_location_location}
301 updates[CONF_MAC] = self.
_mac_mac
305 """Get list of unconfigured DLNA devices discovered by SSDP."""
306 LOGGER.debug(
"_get_discoveries")
310 for udn_st
in DmrDevice.DEVICE_TYPES:
311 st_discoveries = await ssdp.async_get_discovery_info_by_st(
314 discoveries.extend(st_discoveries)
317 current_unique_ids = {
321 return [disc
for disc
in discoveries
if disc.ssdp_udn
not in current_unique_ids]
325 """Handle a DLNA DMR options flow.
327 Configures the single instance and updates the existing config entry.
331 self, user_input: dict[str, Any] |
None =
None
332 ) -> ConfigFlowResult:
333 """Manage the options."""
334 errors: dict[str, str] = {}
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
346 if callback_url_override:
347 cv.url(callback_url_override)
349 errors[
"base"] =
"invalid_url"
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]
360 fields: VolDictType = {}
363 """Add a field to with a suggested value.
365 For bools, use the existing value as default, or fallback to False.
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
373 vol.Optional(key, description={
"suggested_value": suggested_value})
384 data_schema=vol.Schema(fields),
390 """Return True if this device should be ignored for discovery.
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.
397 if len(discovery_info.x_homeassistant_matching_domains) > 1:
399 "Ignoring device supported by multiple integrations: %s",
400 discovery_info.x_homeassistant_matching_domains,
406 discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE)
407 not in DmrDevice.DEVICE_TYPES
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()
417 if manufacturer.startswith(
"xbmc")
or model ==
"kodi":
420 if "philips" in manufacturer
and "tv" in model:
425 if manufacturer.startswith(
"samsung")
and "tv" in model:
428 if manufacturer.startswith(
"lg")
and "tv" in model:
436 """Determine if discovery is a complete DLNA DMR device.
438 Use the discovery_info instead of DmrDevice.is_profile_device to avoid
439 contacting the device again.
442 discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST)
443 if not discovery_service_list:
446 services = discovery_service_list.get(
"service")
448 discovery_service_ids: set[str] = set()
449 elif isinstance(services, list):
450 discovery_service_ids = {service.get(
"serviceId")
for service
in services}
453 discovery_service_ids = {services.get(
"serviceId")}
455 if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids):
462 """Get mac address from host name, IPv4 address, or IPv6 address."""
464 mac_address: str |
None
468 ip_addr = ip_address(host)
470 mac_address = await hass.async_add_executor_job(
471 partial(get_mac_address, hostname=host)
474 if ip_addr.version == 4:
475 mac_address = await hass.async_add_executor_job(
476 partial(get_mac_address, ip=host)
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))
488 return dr.format_mac(mac_address)
ConfigFlowResult async_step_ssdp(self, ssdp.SsdpServiceInfo discovery_info)
ConfigFlowResult async_step_user(self, FlowInput user_input=None)
None _async_connect(self)
None _async_set_info_from_discovery(self, ssdp.SsdpServiceInfo discovery_info, bool abort_if_configured=True)
ConfigFlowResult async_step_manual(self, FlowInput user_input=None)
list[ssdp.SsdpServiceInfo] _async_get_discoveries(self)
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
ConfigFlowResult _create_entry(self)
ConfigFlowResult async_step_ignore(self, Mapping[str, Any] user_input)
ConfigFlowResult async_step_confirm(self, FlowInput user_input=None)
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
None _set_confirm_only(self)
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)
ConfigEntry config_entry(self)
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)
str|None _async_get_mac_address(HomeAssistant hass, str host)
bool _is_ignored_device(ssdp.SsdpServiceInfo discovery_info)
bool _is_dmr_device(ssdp.SsdpServiceInfo discovery_info)
DlnaDmrData get_domain_data(HomeAssistant hass)