Refactor zeroconf task handling (#88204)

* Refactor zeroconf task handling

- Avoid the need to create tasks for most callbacks
- Fixes the untracked task that could get unexpectedly GCed

* be consistant

* be consistant

* fix zeroconf tests

* runtime

* Revert "runtime"

This reverts commit 19e6b6183759279490efbed3129dffd447dc4808.

* precalc

* refactor

* tweak

* update tests
This commit is contained in:
J. Nick Koston 2023-02-15 20:44:11 -06:00 committed by GitHub
parent c0e22be7a8
commit c83ea297b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 346 additions and 112 deletions

View File

@ -35,9 +35,8 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import ( from homeassistant.loader import (
Integration, HomeKitDiscoveredIntegration,
async_get_homekit, async_get_homekit,
async_get_integration,
async_get_zeroconf, async_get_zeroconf,
bind_hass, bind_hass,
) )
@ -348,7 +347,7 @@ class ZeroconfDiscovery:
hass: HomeAssistant, hass: HomeAssistant,
zeroconf: HaZeroconf, zeroconf: HaZeroconf,
zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]],
homekit_models: dict[str, str], homekit_models: dict[str, HomeKitDiscoveredIntegration],
ipv6: bool, ipv6: bool,
) -> None: ) -> None:
"""Init discovery.""" """Init discovery."""
@ -398,12 +397,6 @@ class ZeroconfDiscovery:
if state_change == ServiceStateChange.Removed: if state_change == ServiceStateChange.Removed:
return return
asyncio.create_task(self._process_service_update(zeroconf, service_type, name))
async def _process_service_update(
self, zeroconf: HaZeroconf, service_type: str, name: str
) -> None:
"""Process a zeroconf update."""
try: try:
async_service_info = AsyncServiceInfo(service_type, name) async_service_info = AsyncServiceInfo(service_type, name)
except BadTypeInNameException as ex: except BadTypeInNameException as ex:
@ -411,24 +404,53 @@ class ZeroconfDiscovery:
# This is a bug in the device firmware and we should ignore it # This is a bug in the device firmware and we should ignore it
_LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex)
return return
await async_service_info.async_request(zeroconf, 3000)
if async_service_info.load_from_cache(zeroconf):
self._async_process_service_update(async_service_info, service_type, name)
else:
self.hass.async_create_task(
self._async_lookup_and_process_service_update(
zeroconf, async_service_info, service_type, name
)
)
async def _async_lookup_and_process_service_update(
self,
zeroconf: HaZeroconf,
async_service_info: AsyncServiceInfo,
service_type: str,
name: str,
) -> None:
"""Update and process a zeroconf update."""
await async_service_info.async_request(zeroconf, 3000)
self._async_process_service_update(async_service_info, service_type, name)
@callback
def _async_process_service_update(
self, async_service_info: AsyncServiceInfo, service_type: str, name: str
) -> None:
"""Process a zeroconf update."""
info = info_from_service(async_service_info) info = info_from_service(async_service_info)
if not info: if not info:
# Prevent the browser thread from collapsing # Prevent the browser thread from collapsing
_LOGGER.debug("Failed to get addresses for device %s", name) _LOGGER.debug("Failed to get addresses for device %s", name)
return return
_LOGGER.debug("Discovered new device %s %s", name, info) _LOGGER.debug("Discovered new device %s %s", name, info)
props: dict[str, str] = info.properties props: dict[str, str] = info.properties
domain = None domain = None
# If we can handle it as a HomeKit discovery, we do that here. # If we can handle it as a HomeKit discovery, we do that here.
if service_type in HOMEKIT_TYPES and ( if service_type in HOMEKIT_TYPES and (
domain := async_get_homekit_discovery_domain(self.homekit_models, props) homekit_model := async_get_homekit_discovery_domain(
self.homekit_models, props
)
): ):
domain = homekit_model.domain
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info self.hass,
homekit_model.domain,
{"source": config_entries.SOURCE_HOMEKIT},
info,
) )
# Continue on here as homekit_controller # Continue on here as homekit_controller
# still needs to get updates on devices # still needs to get updates on devices
@ -437,27 +459,15 @@ class ZeroconfDiscovery:
# We only send updates to homekit_controller # We only send updates to homekit_controller
# if the device is already paired in order to avoid # if the device is already paired in order to avoid
# offering a second discovery for the same device # offering a second discovery for the same device
if not is_homekit_paired(props): if not is_homekit_paired(props) and not homekit_model.always_discover:
integration: Integration = await async_get_integration( # If the device is paired with HomeKit we must send on
self.hass, domain # the update to homekit_controller so it can see when
) # the 'c#' field is updated. This is used to detect
# Since we prefer local control, if the integration that is being # when the device has been reset or updated.
# discovered is cloud AND the homekit device is UNPAIRED we still
# want to discovery it.
# #
# Additionally if the integration is polling, HKC offers a local # If the device is not paired and we should not always
# push experience for the user to control the device so we want # discover it, we can stop here.
# to offer that as well. return
#
# As soon as the device becomes paired, the config flow will be
# dismissed in the event the user does not want to pair
# with Home Assistant.
#
if not integration.iot_class or (
not integration.iot_class.startswith("cloud")
and "polling" not in integration.iot_class
):
return
match_data: dict[str, str] = {} match_data: dict[str, str] = {}
for key in LOWER_MATCH_ATTRS: for key in LOWER_MATCH_ATTRS:
@ -495,8 +505,8 @@ class ZeroconfDiscovery:
def async_get_homekit_discovery_domain( def async_get_homekit_discovery_domain(
homekit_models: dict[str, str], props: dict[str, Any] homekit_models: dict[str, HomeKitDiscoveredIntegration], props: dict[str, Any]
) -> str | None: ) -> HomeKitDiscoveredIntegration | None:
"""Handle a HomeKit discovery. """Handle a HomeKit discovery.
Return the domain to forward the discovery data to Return the domain to forward the discovery data to

View File

@ -4,65 +4,242 @@ To update, run python3 -m script.hassfest
""" """
HOMEKIT = { HOMEKIT = {
"3810X": "roku", "3810X": {
"3820X": "roku", "always_discover": True,
"4660X": "roku", "domain": "roku",
"7820X": "roku", },
"819LMB": "myq", "3820X": {
"AC02": "tado", "always_discover": True,
"Abode": "abode", "domain": "roku",
"BSB002": "hue", },
"C105X": "roku", "4660X": {
"C135X": "roku", "always_discover": True,
"EB-*": "ecobee", "domain": "roku",
"Escea": "escea", },
"HHKBridge*": "hive", "7820X": {
"Healty Home Coach": "netatmo", "always_discover": True,
"Iota": "abode", "domain": "roku",
"LIFX A19": "lifx", },
"LIFX BR30": "lifx", "819LMB": {
"LIFX Beam": "lifx", "always_discover": True,
"LIFX Candle": "lifx", "domain": "myq",
"LIFX Clean": "lifx", },
"LIFX Color": "lifx", "AC02": {
"LIFX DLCOL": "lifx", "always_discover": True,
"LIFX DLWW": "lifx", "domain": "tado",
"LIFX Dlight": "lifx", },
"LIFX Downlight": "lifx", "Abode": {
"LIFX Filament": "lifx", "always_discover": True,
"LIFX GU10": "lifx", "domain": "abode",
"LIFX Lightstrip": "lifx", },
"LIFX Mini": "lifx", "BSB002": {
"LIFX Nightvision": "lifx", "always_discover": False,
"LIFX Pls": "lifx", "domain": "hue",
"LIFX Plus": "lifx", },
"LIFX Tile": "lifx", "C105X": {
"LIFX White": "lifx", "always_discover": True,
"LIFX Z": "lifx", "domain": "roku",
"MYQ": "myq", },
"NL29": "nanoleaf", "C135X": {
"NL42": "nanoleaf", "always_discover": True,
"NL47": "nanoleaf", "domain": "roku",
"NL48": "nanoleaf", },
"NL52": "nanoleaf", "EB-*": {
"NL59": "nanoleaf", "always_discover": True,
"Netatmo Relay": "netatmo", "domain": "ecobee",
"PowerView": "hunterdouglas_powerview", },
"Presence": "netatmo", "Escea": {
"Rachio": "rachio", "always_discover": False,
"SPK5": "rainmachine", "domain": "escea",
"Sensibo": "sensibo", },
"Smart Bridge": "lutron_caseta", "HHKBridge*": {
"Socket": "wemo", "always_discover": True,
"TRADFRI": "tradfri", "domain": "hive",
"Touch HD": "rainmachine", },
"Welcome": "netatmo", "Healty Home Coach": {
"Wemo": "wemo", "always_discover": True,
"YL*": "yeelight", "domain": "netatmo",
"ecobee*": "ecobee", },
"iSmartGate": "gogogate2", "Iota": {
"iZone": "izone", "always_discover": True,
"tado": "tado", "domain": "abode",
},
"LIFX A19": {
"always_discover": True,
"domain": "lifx",
},
"LIFX BR30": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Beam": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Candle": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Clean": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Color": {
"always_discover": True,
"domain": "lifx",
},
"LIFX DLCOL": {
"always_discover": True,
"domain": "lifx",
},
"LIFX DLWW": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Dlight": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Downlight": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Filament": {
"always_discover": True,
"domain": "lifx",
},
"LIFX GU10": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Lightstrip": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Mini": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Nightvision": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Pls": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Plus": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Tile": {
"always_discover": True,
"domain": "lifx",
},
"LIFX White": {
"always_discover": True,
"domain": "lifx",
},
"LIFX Z": {
"always_discover": True,
"domain": "lifx",
},
"MYQ": {
"always_discover": True,
"domain": "myq",
},
"NL29": {
"always_discover": False,
"domain": "nanoleaf",
},
"NL42": {
"always_discover": False,
"domain": "nanoleaf",
},
"NL47": {
"always_discover": False,
"domain": "nanoleaf",
},
"NL48": {
"always_discover": False,
"domain": "nanoleaf",
},
"NL52": {
"always_discover": False,
"domain": "nanoleaf",
},
"NL59": {
"always_discover": False,
"domain": "nanoleaf",
},
"Netatmo Relay": {
"always_discover": True,
"domain": "netatmo",
},
"PowerView": {
"always_discover": True,
"domain": "hunterdouglas_powerview",
},
"Presence": {
"always_discover": True,
"domain": "netatmo",
},
"Rachio": {
"always_discover": True,
"domain": "rachio",
},
"SPK5": {
"always_discover": True,
"domain": "rainmachine",
},
"Sensibo": {
"always_discover": True,
"domain": "sensibo",
},
"Smart Bridge": {
"always_discover": False,
"domain": "lutron_caseta",
},
"Socket": {
"always_discover": False,
"domain": "wemo",
},
"TRADFRI": {
"always_discover": True,
"domain": "tradfri",
},
"Touch HD": {
"always_discover": True,
"domain": "rainmachine",
},
"Welcome": {
"always_discover": True,
"domain": "netatmo",
},
"Wemo": {
"always_discover": False,
"domain": "wemo",
},
"YL*": {
"always_discover": False,
"domain": "yeelight",
},
"ecobee*": {
"always_discover": True,
"domain": "ecobee",
},
"iSmartGate": {
"always_discover": True,
"domain": "gogogate2",
},
"iZone": {
"always_discover": True,
"domain": "izone",
},
"tado": {
"always_discover": True,
"domain": "tado",
},
} }
ZEROCONF = { ZEROCONF = {

View File

@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
import functools as ft import functools as ft
import importlib import importlib
import logging import logging
@ -118,6 +119,14 @@ class USBMatcher(USBMatcherRequired, USBMatcherOptional):
"""Matcher for the bluetooth integration.""" """Matcher for the bluetooth integration."""
@dataclass
class HomeKitDiscoveredIntegration:
"""HomeKit model."""
domain: str
always_discover: bool
class Manifest(TypedDict, total=False): class Manifest(TypedDict, total=False):
"""Integration manifest. """Integration manifest.
@ -410,10 +419,30 @@ async def async_get_usb(hass: HomeAssistant) -> list[USBMatcher]:
return usb return usb
async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: def homekit_always_discover(iot_class: str | None) -> bool:
"""Return cached list of homekit models.""" """Return if we should always offer HomeKit control for a device."""
#
# Since we prefer local control, if the integration that is being
# discovered is cloud AND the HomeKit device is UNPAIRED we still
# want to discovery it.
#
# Additionally if the integration is polling, HKC offers a local
# push experience for the user to control the device so we want
# to offer that as well.
#
return not iot_class or (iot_class.startswith("cloud") or "polling" in iot_class)
homekit: dict[str, str] = HOMEKIT.copy()
async def async_get_homekit(
hass: HomeAssistant,
) -> dict[str, HomeKitDiscoveredIntegration]:
"""Return cached list of homekit models."""
homekit: dict[str, HomeKitDiscoveredIntegration] = {
model: HomeKitDiscoveredIntegration(
cast(str, details["domain"]), cast(bool, details["always_discover"])
)
for model, details in HOMEKIT.items()
}
integrations = await async_get_custom_components(hass) integrations = await async_get_custom_components(hass)
for integration in integrations.values(): for integration in integrations.values():
@ -424,7 +453,10 @@ async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]:
): ):
continue continue
for model in integration.homekit["models"]: for model in integration.homekit["models"]:
homekit[model] = integration.domain homekit[model] = HomeKitDiscoveredIntegration(
integration.domain,
homekit_always_discover(integration.iot_class),
)
return homekit return homekit

View File

@ -3,7 +3,10 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from homeassistant.loader import async_process_zeroconf_match_dict from homeassistant.loader import (
async_process_zeroconf_match_dict,
homekit_always_discover,
)
from .model import Config, Integration from .model import Config, Integration
from .serializer import format_python_namespace from .serializer import format_python_namespace
@ -12,7 +15,7 @@ from .serializer import format_python_namespace
def generate_and_validate(integrations: dict[str, Integration]) -> str: def generate_and_validate(integrations: dict[str, Integration]) -> str:
"""Validate and generate zeroconf data.""" """Validate and generate zeroconf data."""
service_type_dict = defaultdict(list) service_type_dict = defaultdict(list)
homekit_dict: dict[str, str] = {} homekit_dict: dict[str, dict[str, str]] = {}
for domain in sorted(integrations): for domain in sorted(integrations):
integration = integrations[domain] integration = integrations[domain]
@ -42,7 +45,12 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str:
) )
break break
homekit_dict[model] = domain homekit_dict[model] = {
"domain": domain,
"always_discover": homekit_always_discover(
integration.manifest["iot_class"]
),
}
# HomeKit models are matched on starting string, make sure none overlap. # HomeKit models are matched on starting string, make sure none overlap.
warned = set() warned = set()

View File

@ -542,7 +542,7 @@ async def test_homekit_match_partial_space(hass, mock_async_zeroconf):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"LIFX": "lifx"}, {"LIFX": {"domain": "lifx", "always_discover": True}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -578,7 +578,7 @@ async def test_device_with_invalid_name(hass, mock_async_zeroconf, caplog):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"LIFX": "lifx"}, {"LIFX": {"domain": "lifx", "always_discover": True}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -609,7 +609,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"Smart Bridge": "lutron_caseta"}, {"Smart Bridge": {"domain": "lutron_caseta", "always_discover": False}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -638,7 +638,11 @@ async def test_homekit_match_partial_fnmatch(hass, mock_async_zeroconf):
zc_gen.ZEROCONF, zc_gen.ZEROCONF,
{"_hap._tcp.local.": [{"domain": "homekit_controller"}]}, {"_hap._tcp.local.": [{"domain": "homekit_controller"}]},
clear=True, clear=True,
), patch.dict(zc_gen.HOMEKIT, {"YLDP*": "yeelight"}, clear=True), patch.object( ), patch.dict(
zc_gen.HOMEKIT,
{"YLDP*": {"domain": "yeelight", "always_discover": False}},
clear=True,
), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object( ) as mock_config_flow, patch.object(
zeroconf, zeroconf,
@ -667,7 +671,7 @@ async def test_homekit_match_full(hass, mock_async_zeroconf):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"BSB002": "hue"}, {"BSB002": {"domain": "hue", "always_discover": False}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -698,7 +702,10 @@ async def test_homekit_already_paired(hass, mock_async_zeroconf):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"AC02": "tado", "tado": "tado"}, {
"AC02": {"domain": "tado", "always_discover": True},
"tado": {"domain": "tado", "always_discover": True},
},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -730,7 +737,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf):
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"Smart Bridge": "lutron_caseta"}, {"Smart Bridge": {"domain": "lutron_caseta", "always_discover": False}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -794,7 +801,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud(
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"Rachio": "rachio"}, {"Rachio": {"domain": "rachio", "always_discover": True}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"
@ -834,7 +841,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling(
clear=True, clear=True,
), patch.dict( ), patch.dict(
zc_gen.HOMEKIT, zc_gen.HOMEKIT,
{"iSmartGate": "gogogate2"}, {"iSmartGate": {"domain": "gogogate2", "always_discover": True}},
clear=True, clear=True,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init" hass.config_entries.flow, "async_init"

View File

@ -623,8 +623,8 @@ async def test_get_homekit(hass: HomeAssistant) -> None:
"test_2": test_2_integration, "test_2": test_2_integration,
} }
homekit = await loader.async_get_homekit(hass) homekit = await loader.async_get_homekit(hass)
assert homekit["test_1"] == "test_1" assert homekit["test_1"] == loader.HomeKitDiscoveredIntegration("test_1", True)
assert homekit["test_2"] == "test_2" assert homekit["test_2"] == loader.HomeKitDiscoveredIntegration("test_2", True)
async def test_get_ssdp(hass: HomeAssistant) -> None: async def test_get_ssdp(hass: HomeAssistant) -> None: