Michael Chisholm a28fd7d61b
Config-flow for DLNA-DMR integration (#55267)
* Modernize dlna_dmr component: configflow, test, types

* Support config-flow with ssdp discovery
* Add unit tests
* Enforce strict typing
* Gracefully handle network devices (dis)appearing

* Fix Aiohttp mock response headers type to match actual response class

* Fixes from code review

* Fixes from code review

* Import device config in flow if unavailable at hass start

* Support SSDP advertisements

* Ignore bad BOOTID, fix ssdp:byebye handling

* Only listen for events on interface connected to device

* Release all listeners when entities are removed

* Warn about deprecated dlna_dmr configuration

* Use sublogger for dlna_dmr.config_flow for easier filtering

* Tests for dlna_dmr.data module

* Rewrite DMR tests for HA style

* Fix DMR strings: "Digital Media *Renderer*"

* Update DMR entity state and device info when changed

* Replace deprecated async_upnp_client State with TransportState

* supported_features are dynamic, based on current device state

* Cleanup fully when subscription fails

* Log warnings when device connection fails unexpectedly

* Set PARALLEL_UPDATES to unlimited

* Fix spelling

* Fixes from code review

* Simplify has & can checks to just can, which includes has

* Treat transitioning state as playing (not idle) to reduce UI jerking

* Test if device is usable

* Handle ssdp:update messages properly

* Fix _remove_ssdp_callbacks being shared by all DlnaDmrEntity instances

* Fix tests for transitioning state

* Mock DmrDevice.is_profile_device (added to support embedded devices)

* Use ST & NT SSDP headers to find DMR devices, not deviceType

The deviceType is extracted from the device's description XML, and will not
be what we want when dealing with embedded devices.

* Use UDN from SSDP headers, not device description, as unique_id

The SSDP headers have the UDN of the embedded device that we're interested
in, whereas the device description (`ATTR_UPNP_UDN`) field will always be
for the root device.

* Fix DMR string English localization

* Test config flow with UDN from SSDP headers

* Bump async-upnp-client==0.22.1, fix flake8 error

* fix test for remapping

* DMR HA Device connections based on root and embedded UDN

* DmrDevice's UpnpDevice is now named profile_device

* Use device type from SSDP headers, not device description

* Mark dlna_dmr constants as Final

* Use embedded device UDN and type for unique ID when connected via URL

* More informative connection error messages

* Also match SSDP messages on NT headers

The NT header is to ssdp:alive messages what ST is to M-SEARCH responses.

* Bump async-upnp-client==0.22.2

* fix merge

* Bump async-upnp-client==0.22.3

Co-authored-by: Steven Looman <steven.looman@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2021-09-27 15:47:01 -05:00

236 lines
6.9 KiB
Python

"""Starts a service to scan in intervals for new devices."""
from __future__ import annotations
from datetime import timedelta
import json
import logging
from netdisco.discovery import NetworkDiscovery
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_discover, async_load_platform
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.loader import async_get_zeroconf
import homeassistant.util.dt as dt_util
DOMAIN = "discovery"
SCAN_INTERVAL = timedelta(seconds=300)
SERVICE_APPLE_TV = "apple_tv"
SERVICE_DAIKIN = "daikin"
SERVICE_DLNA_DMR = "dlna_dmr"
SERVICE_ENIGMA2 = "enigma2"
SERVICE_HASS_IOS_APP = "hass_ios"
SERVICE_HASSIO = "hassio"
SERVICE_HEOS = "heos"
SERVICE_KONNECTED = "konnected"
SERVICE_MOBILE_APP = "hass_mobile_app"
SERVICE_NETGEAR = "netgear_router"
SERVICE_OCTOPRINT = "octoprint"
SERVICE_SABNZBD = "sabnzbd"
SERVICE_SAMSUNG_PRINTER = "samsung_printer"
SERVICE_TELLDUSLIVE = "tellstick"
SERVICE_YEELIGHT = "yeelight"
SERVICE_WEMO = "belkin_wemo"
SERVICE_WINK = "wink"
SERVICE_XIAOMI_GW = "xiaomi_gw"
# These have custom protocols
CONFIG_ENTRY_HANDLERS = {
SERVICE_TELLDUSLIVE: "tellduslive",
"logitech_mediaserver": "squeezebox",
}
# These have no config flows
SERVICE_HANDLERS = {
SERVICE_ENIGMA2: ("media_player", "enigma2"),
SERVICE_SABNZBD: ("sabnzbd", None),
"yamaha": ("media_player", "yamaha"),
"frontier_silicon": ("media_player", "frontier_silicon"),
"openhome": ("media_player", "openhome"),
"bose_soundtouch": ("media_player", "soundtouch"),
"bluesound": ("media_player", "bluesound"),
"lg_smart_device": ("media_player", "lg_soundbar"),
}
OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {}
MIGRATED_SERVICE_HANDLERS = [
SERVICE_APPLE_TV,
"axis",
"deconz",
SERVICE_DAIKIN,
"denonavr",
SERVICE_DLNA_DMR,
"esphome",
"google_cast",
SERVICE_HASS_IOS_APP,
SERVICE_HASSIO,
SERVICE_HEOS,
"harmony",
"homekit",
"ikea_tradfri",
"kodi",
SERVICE_KONNECTED,
SERVICE_MOBILE_APP,
SERVICE_NETGEAR,
SERVICE_OCTOPRINT,
"philips_hue",
SERVICE_SAMSUNG_PRINTER,
"sonos",
"songpal",
SERVICE_WEMO,
SERVICE_WINK,
SERVICE_XIAOMI_GW,
"volumio",
SERVICE_YEELIGHT,
"nanoleaf_aurora",
]
DEFAULT_ENABLED = (
list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS
)
DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS
CONF_IGNORE = "ignore"
CONF_ENABLE = "enable"
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(DOMAIN): vol.Schema(
{
vol.Optional(CONF_IGNORE, default=[]): vol.All(
cv.ensure_list, [vol.In(DEFAULT_ENABLED)]
),
vol.Optional(CONF_ENABLE, default=[]): vol.All(
cv.ensure_list, [vol.In(DEFAULT_DISABLED + DEFAULT_ENABLED)]
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass, config):
"""Start a discovery service."""
logger = logging.getLogger(__name__)
netdisco = NetworkDiscovery()
already_discovered = set()
if DOMAIN in config:
# Platforms ignore by config
ignored_platforms = config[DOMAIN][CONF_IGNORE]
# Optional platforms enabled by config
enabled_platforms = config[DOMAIN][CONF_ENABLE]
else:
ignored_platforms = []
enabled_platforms = []
for platform in enabled_platforms:
if platform in DEFAULT_ENABLED:
logger.warning(
"Please remove %s from your discovery.enable configuration "
"as it is now enabled by default",
platform,
)
zeroconf_instance = await zeroconf.async_get_instance(hass)
# Do not scan for types that have already been converted
# as it will generate excess network traffic for questions
# the zeroconf instance already knows the answers
zeroconf_types = list(await async_get_zeroconf(hass))
async def new_service_found(service, info):
"""Handle a new service if one is found."""
if service in MIGRATED_SERVICE_HANDLERS:
return
if service in ignored_platforms:
logger.info("Ignoring service: %s %s", service, info)
return
discovery_hash = json.dumps([service, info], sort_keys=True)
if discovery_hash in already_discovered:
logger.debug("Already discovered service %s %s.", service, info)
return
already_discovered.add(discovery_hash)
if service in CONFIG_ENTRY_HANDLERS:
await hass.config_entries.flow.async_init(
CONFIG_ENTRY_HANDLERS[service],
context={"source": config_entries.SOURCE_DISCOVERY},
data=info,
)
return
comp_plat = SERVICE_HANDLERS.get(service)
if not comp_plat and service in enabled_platforms:
comp_plat = OPTIONAL_SERVICE_HANDLERS[service]
# We do not know how to handle this service.
if not comp_plat:
logger.debug("Unknown service discovered: %s %s", service, info)
return
logger.info("Found new service: %s %s", service, info)
component, platform = comp_plat
if platform is None:
await async_discover(hass, service, info, component, config)
else:
await async_load_platform(hass, component, platform, info, config)
async def scan_devices(now):
"""Scan for devices."""
try:
results = await hass.async_add_executor_job(
_discover, netdisco, zeroconf_instance, zeroconf_types
)
for result in results:
hass.async_create_task(new_service_found(*result))
except OSError:
logger.error("Network is unreachable")
async_track_point_in_utc_time(
hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL
)
@callback
def schedule_first(event):
"""Schedule the first discovery when Home Assistant starts up."""
async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow())
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first)
return True
def _discover(netdisco, zeroconf_instance, zeroconf_types):
"""Discover devices."""
results = []
try:
netdisco.scan(
zeroconf_instance=zeroconf_instance, suppress_mdns_types=zeroconf_types
)
for disc in netdisco.discover():
for service in netdisco.get_info(disc):
results.append((disc, service))
finally:
netdisco.stop()
return results