Refactor zeroconf setup to be async (#39955)

* Refactor zeroconf setup to be async

Most of the setup was calling back to async because
we were setting up listeners.  Since we only need
to jump into the executor to create the zeroconf
instance, its much faster to setup in async.

In testing this cut the setup time in half
or better.

* partial revert to after_deps
pull/40048/head
J. Nick Koston 2020-09-13 18:06:19 -05:00 committed by GitHub
parent 00acb180d6
commit 7b016063ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 84 additions and 91 deletions

View File

@ -1,6 +1,6 @@
"""Support for exposing Home Assistant via Zeroconf."""
import asyncio
import fnmatch
from functools import partial
import ipaddress
import logging
import socket
@ -81,26 +81,21 @@ CONFIG_SCHEMA = vol.Schema(
@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)
return await _async_get_instance(hass)
def _get_instance(hass, default_interface=False, ipv6=True):
"""Create an instance."""
async def _async_get_instance(hass, **zcargs):
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 = await hass.async_add_executor_job(partial(HaZeroconf, **zcargs))
zeroconf = HaZeroconf(**zc_args)
install_multiple_zeroconf_catcher(zeroconf)
def stop_zeroconf(_):
def _stop_zeroconf(_):
"""Stop Zeroconf."""
zeroconf.ha_close()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_zeroconf)
return zeroconf
@ -135,24 +130,42 @@ class HaZeroconf(Zeroconf):
ha_close = Zeroconf.close
def setup(hass, config):
async def async_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),
zc_args = {}
if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE):
zc_args["interfaces"] = InterfaceChoice.Default
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
zc_args["ip_version"] = IPVersion.V4Only
zeroconf = hass.data[DOMAIN] = await _async_get_instance(hass, **zc_args)
async def _async_zeroconf_hass_start(_event):
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
"""
uuid = await hass.helpers.instance_id.async_get()
await hass.async_add_executor_job(
_register_hass_zc_service, hass, zeroconf, uuid
)
async def _async_zeroconf_hass_started(_event):
"""Start the service browser."""
await _async_start_zeroconf_browser(hass, zeroconf)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_zeroconf_hass_start)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _async_zeroconf_hass_started
)
install_multiple_zeroconf_catcher(zeroconf)
return True
def _register_hass_zc_service(hass, zeroconf, uuid):
# Get instance UUID
uuid = asyncio.run_coroutine_threadsafe(
hass.helpers.instance_id.async_get(), hass.loop
).result()
valid_location_name = _truncate_location_name_to_valid(hass.config.location_name)
params = {
@ -199,23 +212,25 @@ def setup(hass, config):
properties=params,
)
def zeroconf_hass_start(_event):
"""Expose Home Assistant on zeroconf when it starts.
_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"
)
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)
async def _async_start_zeroconf_browser(hass, zeroconf):
"""Start the zeroconf browser."""
zeroconf_types = {}
homekit_models = {}
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
types = list(zeroconf_types)
if HOMEKIT_TYPE not in zeroconf_types:
types.append(HOMEKIT_TYPE)
def service_update(zeroconf, service_type, name, state_change):
"""Service state changed."""
@ -292,25 +307,8 @@ def setup(hass, config):
)
)
async def zeroconf_hass_started(_event):
"""Start the service browser."""
nonlocal zeroconf_types
nonlocal homekit_models
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
types = list(zeroconf_types)
if HOMEKIT_TYPE not in zeroconf_types:
types.append(HOMEKIT_TYPE)
_LOGGER.debug("Starting Zeroconf browser")
HaServiceBrowser(zeroconf, types, handlers=[service_update])
hass.bus.listen_once(EVENT_HOMEASSISTANT_STARTED, zeroconf_hass_started)
return True
_LOGGER.debug("Starting Zeroconf browser")
HaServiceBrowser(zeroconf, types, handlers=[service_update])
def handle_homekit(hass, homekit_models, info) -> bool:

View File

@ -1,4 +1,5 @@
"""Test Home Assistant Cast."""
from homeassistant.components.cast import home_assistant_cast
from homeassistant.config import async_process_ha_core_config
@ -6,7 +7,7 @@ from tests.async_mock import patch
from tests.common import MockConfigEntry, async_mock_signal
async def test_service_show_view(hass):
async def test_service_show_view(hass, mock_zeroconf):
"""Test we don't set app id in prod."""
await async_process_ha_core_config(
hass,
@ -33,7 +34,7 @@ async def test_service_show_view(hass):
assert url_path is None
async def test_service_show_view_dashboard(hass):
async def test_service_show_view_dashboard(hass, mock_zeroconf):
"""Test casting a specific dashboard."""
await async_process_ha_core_config(
hass,
@ -60,7 +61,7 @@ async def test_service_show_view_dashboard(hass):
assert url_path == "mock-dashboard"
async def test_use_cloud_url(hass):
async def test_use_cloud_url(hass, mock_zeroconf):
"""Test that we fall back to cloud url."""
await async_process_ha_core_config(
hass,

View File

@ -6,13 +6,6 @@ from homeassistant.setup import async_setup_component
from tests.async_mock import patch
@pytest.fixture(autouse=True)
def mock_zeroconf():
"""Mock zeroconf."""
with patch("homeassistant.components.zeroconf.HaZeroconf"):
yield
@pytest.fixture(autouse=True)
def mock_ssdp():
"""Mock ssdp."""
@ -34,6 +27,6 @@ def recorder_url_mock():
yield
async def test_setup(hass):
async def test_setup(hass, mock_zeroconf):
"""Test setup."""
assert await async_setup_component(hass, "default_config", {"foo": "bar"})

View File

@ -37,7 +37,9 @@ def netdisco_mock():
async def mock_discovery(hass, discoveries, config=BASE_CONFIG):
"""Mock discoveries."""
with patch("homeassistant.components.zeroconf.async_get_instance"):
with patch("homeassistant.components.zeroconf.async_get_instance"), patch(
"homeassistant.components.zeroconf.async_setup", return_value=True
):
assert await async_setup_component(hass, "discovery", config)
await hass.async_block_till_done()
await hass.async_start()

View File

@ -29,10 +29,3 @@ def events(hass):
EVENT_HOMEKIT_CHANGED, ha_callback(lambda e: events.append(e))
)
yield events
@pytest.fixture
def mock_zeroconf():
"""Mock zeroconf."""
with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
yield mock_zc.return_value

View File

@ -1018,6 +1018,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf):
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT},
options={},
)
assert await async_setup_component(hass, "zeroconf", {"zeroconf": {}})
system_zc = await zeroconf.async_get_instance(hass)
with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch(

View File

@ -1,11 +0,0 @@
"""conftest for zeroconf."""
import pytest
from tests.async_mock import patch
@pytest.fixture
def mock_zeroconf():
"""Mock zeroconf."""
with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
yield mock_zc.return_value

View File

@ -9,7 +9,11 @@ from zeroconf import (
from homeassistant.components import zeroconf
from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.generated import zeroconf as zc_gen
from homeassistant.setup import async_setup_component
@ -128,7 +132,7 @@ async def test_setup_with_overly_long_url_and_name(hass, mock_zeroconf, caplog):
"""Test we still setup with long urls and names."""
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
zeroconf, "HaServiceBrowser", side_effect=service_update_mock
) as mock_service_browser, patch(
), patch(
"homeassistant.components.zeroconf.get_url",
return_value="https://this.url.is.way.too.long/very/deep/path/that/will/make/us/go/over/the/maximum/string/length/and/would/cause/zeroconf/to/fail/to/startup/because/the/key/and/value/can/only/be/255/bytes/and/this/string/is/a/bit/longer/than/the/maximum/length/that/we/allow/for/a/value",
), patch.object(
@ -138,10 +142,9 @@ async def test_setup_with_overly_long_url_and_name(hass, mock_zeroconf, caplog):
):
mock_zeroconf.get_service_info.side_effect = get_service_info_mock
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
assert len(mock_service_browser.mock_calls) == 1
assert "https://this.url.is.way.too.long" in caplog.text
assert "German Umlaut" in caplog.text
@ -461,6 +464,7 @@ async def test_info_from_service_with_addresses(hass):
async def test_get_instance(hass, mock_zeroconf):
"""Test we get an instance."""
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
assert await hass.components.zeroconf.async_get_instance() is mock_zeroconf
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()

View File

@ -3,12 +3,16 @@ import zeroconf
from homeassistant.components.zeroconf import async_get_instance
from homeassistant.components.zeroconf.usage import install_multiple_zeroconf_catcher
from homeassistant.setup import async_setup_component
from tests.async_mock import Mock, patch
DOMAIN = "zeroconf"
async def test_multiple_zeroconf_instances(hass, mock_zeroconf, caplog):
"""Test creating multiple zeroconf throws without an integration."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
zeroconf_instance = await async_get_instance(hass)
@ -22,6 +26,7 @@ async def test_multiple_zeroconf_instances(hass, mock_zeroconf, caplog):
async def test_multiple_zeroconf_instances_gives_shared(hass, mock_zeroconf, caplog):
"""Test creating multiple zeroconf gives the shared instance to an integration."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
zeroconf_instance = await async_get_instance(hass)

View File

@ -395,6 +395,13 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config):
return component
@pytest.fixture
def mock_zeroconf():
"""Mock zeroconf."""
with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
yield mock_zc.return_value
@pytest.fixture
def legacy_patchable_time():
"""Allow time to be patchable by using event listeners instead of asyncio loop."""