Files
core/homeassistant/components/zeroconf/__init__.py
J. Nick Koston 5cf7b1b1bc Ensure we do not start discovered flows until after the started event has fired (#38047)
* Ensure we do not start discovered flows until after the start event has fired

This change makes zeroconf and ssdp match discovery behavior of not
creating config flows until the start event has been fired.  This
prevents config flow creation/dependency installs for discovered
config flows from competing for cpu time during startup.

* Start discovery/service browser/ssdp when EVENT_HOMEASSISTANT_STARTED is fired instead of EVENT_HOMEASSISTANT_START
2020-07-21 14:18:43 -10:00

332 lines
9.5 KiB
Python

"""Support for exposing Home Assistant via Zeroconf."""
import asyncio
import ipaddress
import logging
import socket
import voluptuous as vol
from zeroconf import (
DNSPointer,
DNSRecord,
InterfaceChoice,
IPVersion,
NonUniqueNameException,
ServiceBrowser,
ServiceInfo,
ServiceStateChange,
Zeroconf,
)
from homeassistant import util
from homeassistant.const import (
ATTR_NAME,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
__version__,
)
from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton
_LOGGER = logging.getLogger(__name__)
DOMAIN = "zeroconf"
ATTR_HOST = "host"
ATTR_PORT = "port"
ATTR_HOSTNAME = "hostname"
ATTR_TYPE = "type"
ATTR_PROPERTIES = "properties"
ZEROCONF_TYPE = "_home-assistant._tcp.local."
HOMEKIT_TYPE = "_hap._tcp.local."
CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = False
DEFAULT_IPV6 = True
HOMEKIT_PROPERTIES = "properties"
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
): cv.boolean,
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
@singleton(DOMAIN)
async def async_get_instance(hass):
"""Zeroconf instance to be shared with other integrations that use it."""
return await hass.async_add_executor_job(_get_instance, hass)
def _get_instance(hass, default_interface=False, ipv6=True):
"""Create an instance."""
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
zc_args = {}
if default_interface:
zc_args["interfaces"] = InterfaceChoice.Default
if not ipv6:
zc_args["ip_version"] = IPVersion.V4Only
zeroconf = HaZeroconf(**zc_args)
def stop_zeroconf(_):
"""Stop Zeroconf."""
zeroconf.ha_close()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
return zeroconf
class HaServiceBrowser(ServiceBrowser):
"""ServiceBrowser that only consumes DNSPointer records."""
def update_record(self, zc: "Zeroconf", now: float, record: DNSRecord) -> None:
"""Pre-Filter update_record to DNSPointers for the configured type."""
#
# Each ServerBrowser currently runs in its own thread which
# processes every A or AAAA record update per instance.
#
# As the list of zeroconf names we watch for grows, each additional
# ServiceBrowser would process all the A and AAAA updates on the network.
#
# To avoid overwhemling the system we pre-filter here and only process
# DNSPointers for the configured record name (type)
#
if record.name not in self.types or not isinstance(record, DNSPointer):
return
super().update_record(zc, now, record)
class HaZeroconf(Zeroconf):
"""Zeroconf that cannot be closed."""
def close(self):
"""Fake method to avoid integrations closing it."""
ha_close = Zeroconf.close
def setup(hass, config):
"""Set up Zeroconf and make Home Assistant discoverable."""
zc_config = config.get(DOMAIN, {})
zeroconf = hass.data[DOMAIN] = _get_instance(
hass,
default_interface=zc_config.get(
CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE
),
ipv6=zc_config.get(CONF_IPV6, DEFAULT_IPV6),
)
# Get instance UUID
uuid = asyncio.run_coroutine_threadsafe(
hass.helpers.instance_id.async_get(), hass.loop
).result()
params = {
"location_name": hass.config.location_name,
"uuid": uuid,
"version": __version__,
"external_url": "",
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
try:
params["external_url"] = get_url(hass, allow_internal=False)
except NoURLAvailableError:
pass
try:
params["internal_url"] = get_url(hass, allow_external=False)
except NoURLAvailableError:
pass
# Set old base URL based on external or internal
params["base_url"] = params["external_url"] or params["internal_url"]
host_ip = util.get_local_ip()
try:
host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip)
except OSError:
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)
info = ServiceInfo(
ZEROCONF_TYPE,
name=f"{hass.config.location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
addresses=[host_ip_pton],
port=hass.http.server_port,
properties=params,
)
def zeroconf_hass_start(_event):
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
"""
_LOGGER.info("Starting Zeroconf broadcast")
try:
zeroconf.register_service(info)
except NonUniqueNameException:
_LOGGER.error(
"Home Assistant instance with identical name present in the local network"
)
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start)
def service_update(zeroconf, service_type, name, state_change):
"""Service state changed."""
if state_change != ServiceStateChange.Added:
return
service_info = zeroconf.get_service_info(service_type, name)
if not service_info:
# Prevent the browser thread from collapsing as
# service_info can be None
_LOGGER.debug("Failed to get info for device %s", name)
return
info = info_from_service(service_info)
_LOGGER.debug("Discovered new device %s %s", name, info)
# If we can handle it as a HomeKit discovery, we do that here.
if service_type == HOMEKIT_TYPE:
discovery_was_forwarded = handle_homekit(hass, info)
# Continue on here as homekit_controller
# still needs to get updates on devices
# so it can see when the 'c#' field is updated.
#
# We only send updates to homekit_controller
# if the device is already paired in order to avoid
# offering a second discovery for the same device
if (
discovery_was_forwarded
and HOMEKIT_PROPERTIES in info
and HOMEKIT_PAIRED_STATUS_FLAG in info[HOMEKIT_PROPERTIES]
):
try:
# 0 means paired and not discoverable by iOS clients)
if int(info[HOMEKIT_PROPERTIES][HOMEKIT_PAIRED_STATUS_FLAG]):
return
except ValueError:
# HomeKit pairing status unknown
# likely bad homekit data
return
for domain in ZEROCONF[service_type]:
hass.add_job(
hass.config_entries.flow.async_init(
domain, context={"source": DOMAIN}, data=info
)
)
types = list(ZEROCONF)
if HOMEKIT_TYPE not in ZEROCONF:
types.append(HOMEKIT_TYPE)
def zeroconf_hass_started(_event):
"""Start the service browser."""
_LOGGER.debug("Starting Zeroconf browser")
HaServiceBrowser(zeroconf, types, handlers=[service_update])
hass.bus.listen_once(EVENT_HOMEASSISTANT_STARTED, zeroconf_hass_started)
return True
def handle_homekit(hass, info) -> bool:
"""Handle a HomeKit discovery.
Return if discovery was forwarded.
"""
model = None
props = info.get(HOMEKIT_PROPERTIES, {})
for key in props:
if key.lower() == HOMEKIT_MODEL:
model = props[key]
break
if model is None:
return False
for test_model in HOMEKIT:
if (
model != test_model
and not model.startswith(f"{test_model} ")
and not model.startswith(f"{test_model}-")
):
continue
hass.add_job(
hass.config_entries.flow.async_init(
HOMEKIT[test_model], context={"source": "homekit"}, data=info
)
)
return True
return False
def info_from_service(service):
"""Return prepared info from mDNS entries."""
properties = {"_raw": {}}
for key, value in service.properties.items():
# See https://ietf.org/rfc/rfc6763.html#section-6.4 and
# https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings
# for property keys and values
try:
key = key.decode("ascii")
except UnicodeDecodeError:
_LOGGER.debug(
"Ignoring invalid key provided by [%s]: %s", service.name, key
)
continue
properties["_raw"][key] = value
try:
if isinstance(value, bytes):
properties[key] = value.decode("utf-8")
except UnicodeDecodeError:
pass
address = service.addresses[0]
info = {
ATTR_HOST: str(ipaddress.ip_address(address)),
ATTR_PORT: service.port,
ATTR_HOSTNAME: service.server,
ATTR_TYPE: service.type,
ATTR_NAME: service.name,
ATTR_PROPERTIES: properties,
}
return info