From 01144578c0c3bd211ef3da1a12221899fc8057e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Feb 2023 14:46:45 -0600 Subject: [PATCH] Refactor zeroconf task handling - Avoid the need to create tasks for most callbacks - Fixes the untracked task that could get unexpectedly GCed --- homeassistant/components/zeroconf/__init__.py | 61 ++-- homeassistant/generated/zeroconf.py | 295 ++++++++++++++---- homeassistant/loader.py | 18 +- script/hassfest/zeroconf.py | 7 +- 4 files changed, 296 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 088e46aa1cb..5d9f8c2efff 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -35,9 +35,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( - Integration, + HomeKitModel, async_get_homekit, - async_get_integration, async_get_zeroconf, bind_hass, ) @@ -348,7 +347,7 @@ class ZeroconfDiscovery: hass: HomeAssistant, zeroconf: HaZeroconf, zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], - homekit_models: dict[str, str], + homekit_models: dict[str, HomeKitModel], ipv6: bool, ) -> None: """Init discovery.""" @@ -398,12 +397,6 @@ class ZeroconfDiscovery: if state_change == ServiceStateChange.Removed: 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: async_service_info = AsyncServiceInfo(service_type, name) except BadTypeInNameException as ex: @@ -411,24 +404,53 @@ class ZeroconfDiscovery: # This is a bug in the device firmware and we should ignore it _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) 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) if not info: # Prevent the browser thread from collapsing _LOGGER.debug("Failed to get addresses for device %s", name) return - _LOGGER.debug("Discovered new device %s %s", name, info) props: dict[str, str] = info.properties domain = None # If we can handle it as a HomeKit discovery, we do that here. 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( - 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 # still needs to get updates on devices @@ -438,9 +460,6 @@ class ZeroconfDiscovery: # if the device is already paired in order to avoid # offering a second discovery for the same device if not is_homekit_paired(props): - integration: Integration = await async_get_integration( - self.hass, domain - ) # 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. @@ -453,9 +472,9 @@ class ZeroconfDiscovery: # 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 + if not homekit_model.iot_class or ( + not homekit_model.iot_class.startswith("cloud") + and "polling" not in homekit_model.iot_class ): return @@ -495,8 +514,8 @@ class ZeroconfDiscovery: def async_get_homekit_discovery_domain( - homekit_models: dict[str, str], props: dict[str, Any] -) -> str | None: + homekit_models: dict[str, HomeKitModel], props: dict[str, Any] +) -> HomeKitModel | None: """Handle a HomeKit discovery. Return the domain to forward the discovery data to diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6032ce4bf7d..e985a031985 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -4,65 +4,242 @@ To update, run python3 -m script.hassfest """ HOMEKIT = { - "3810X": "roku", - "3820X": "roku", - "4660X": "roku", - "7820X": "roku", - "819LMB": "myq", - "AC02": "tado", - "Abode": "abode", - "BSB002": "hue", - "C105X": "roku", - "C135X": "roku", - "EB-*": "ecobee", - "Escea": "escea", - "HHKBridge*": "hive", - "Healty Home Coach": "netatmo", - "Iota": "abode", - "LIFX A19": "lifx", - "LIFX BR30": "lifx", - "LIFX Beam": "lifx", - "LIFX Candle": "lifx", - "LIFX Clean": "lifx", - "LIFX Color": "lifx", - "LIFX DLCOL": "lifx", - "LIFX DLWW": "lifx", - "LIFX Dlight": "lifx", - "LIFX Downlight": "lifx", - "LIFX Filament": "lifx", - "LIFX GU10": "lifx", - "LIFX Lightstrip": "lifx", - "LIFX Mini": "lifx", - "LIFX Nightvision": "lifx", - "LIFX Pls": "lifx", - "LIFX Plus": "lifx", - "LIFX Tile": "lifx", - "LIFX White": "lifx", - "LIFX Z": "lifx", - "MYQ": "myq", - "NL29": "nanoleaf", - "NL42": "nanoleaf", - "NL47": "nanoleaf", - "NL48": "nanoleaf", - "NL52": "nanoleaf", - "NL59": "nanoleaf", - "Netatmo Relay": "netatmo", - "PowerView": "hunterdouglas_powerview", - "Presence": "netatmo", - "Rachio": "rachio", - "SPK5": "rainmachine", - "Sensibo": "sensibo", - "Smart Bridge": "lutron_caseta", - "Socket": "wemo", - "TRADFRI": "tradfri", - "Touch HD": "rainmachine", - "Welcome": "netatmo", - "Wemo": "wemo", - "YL*": "yeelight", - "ecobee*": "ecobee", - "iSmartGate": "gogogate2", - "iZone": "izone", - "tado": "tado", + "3810X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "3820X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "4660X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "7820X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "819LMB": { + "integration": "myq", + "iot_class": "cloud_polling", + }, + "AC02": { + "integration": "tado", + "iot_class": "cloud_polling", + }, + "Abode": { + "integration": "abode", + "iot_class": "cloud_push", + }, + "BSB002": { + "integration": "hue", + "iot_class": "local_push", + }, + "C105X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "C135X": { + "integration": "roku", + "iot_class": "local_polling", + }, + "EB-*": { + "integration": "ecobee", + "iot_class": "cloud_polling", + }, + "Escea": { + "integration": "escea", + "iot_class": "local_push", + }, + "HHKBridge*": { + "integration": "hive", + "iot_class": "cloud_polling", + }, + "Healty Home Coach": { + "integration": "netatmo", + "iot_class": "cloud_polling", + }, + "Iota": { + "integration": "abode", + "iot_class": "cloud_push", + }, + "LIFX A19": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX BR30": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Beam": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Candle": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Clean": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Color": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX DLCOL": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX DLWW": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Dlight": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Downlight": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Filament": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX GU10": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Lightstrip": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Mini": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Nightvision": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Pls": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Plus": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Tile": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX White": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "LIFX Z": { + "integration": "lifx", + "iot_class": "local_polling", + }, + "MYQ": { + "integration": "myq", + "iot_class": "cloud_polling", + }, + "NL29": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "NL42": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "NL47": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "NL48": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "NL52": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "NL59": { + "integration": "nanoleaf", + "iot_class": "local_push", + }, + "Netatmo Relay": { + "integration": "netatmo", + "iot_class": "cloud_polling", + }, + "PowerView": { + "integration": "hunterdouglas_powerview", + "iot_class": "local_polling", + }, + "Presence": { + "integration": "netatmo", + "iot_class": "cloud_polling", + }, + "Rachio": { + "integration": "rachio", + "iot_class": "cloud_push", + }, + "SPK5": { + "integration": "rainmachine", + "iot_class": "local_polling", + }, + "Sensibo": { + "integration": "sensibo", + "iot_class": "cloud_polling", + }, + "Smart Bridge": { + "integration": "lutron_caseta", + "iot_class": "local_push", + }, + "Socket": { + "integration": "wemo", + "iot_class": "local_push", + }, + "TRADFRI": { + "integration": "tradfri", + "iot_class": "local_polling", + }, + "Touch HD": { + "integration": "rainmachine", + "iot_class": "local_polling", + }, + "Welcome": { + "integration": "netatmo", + "iot_class": "cloud_polling", + }, + "Wemo": { + "integration": "wemo", + "iot_class": "local_push", + }, + "YL*": { + "integration": "yeelight", + "iot_class": "local_push", + }, + "ecobee*": { + "integration": "ecobee", + "iot_class": "cloud_polling", + }, + "iSmartGate": { + "integration": "gogogate2", + "iot_class": "local_polling", + }, + "iZone": { + "integration": "izone", + "iot_class": "local_polling", + }, + "tado": { + "integration": "tado", + "iot_class": "cloud_polling", + }, } ZEROCONF = { diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 523e2db5159..a26682e322a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import dataclass import functools as ft import importlib import logging @@ -118,6 +119,14 @@ class USBMatcher(USBMatcherRequired, USBMatcherOptional): """Matcher for the bluetooth integration.""" +@dataclass +class HomeKitModel: + """HomeKit model.""" + + domain: str + iot_class: str | None + + class Manifest(TypedDict, total=False): """Integration manifest. @@ -410,10 +419,13 @@ async def async_get_usb(hass: HomeAssistant) -> list[USBMatcher]: return usb -async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: +async def async_get_homekit(hass: HomeAssistant) -> dict[str, HomeKitModel]: """Return cached list of homekit models.""" - homekit: dict[str, str] = HOMEKIT.copy() + homekit: dict[str, HomeKitModel] = { + model: HomeKitModel(details["domain"], details["iot_class"]) + for model, details in HOMEKIT.items() + } integrations = await async_get_custom_components(hass) for integration in integrations.values(): @@ -424,7 +436,7 @@ async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: ): continue for model in integration.homekit["models"]: - homekit[model] = integration.domain + homekit[model] = HomeKitModel(integration.domain, integration.iot_class) return homekit diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 76a7be45c34..edb915085c0 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -12,7 +12,7 @@ from .serializer import format_python_namespace def generate_and_validate(integrations: dict[str, Integration]) -> str: """Validate and generate zeroconf data.""" service_type_dict = defaultdict(list) - homekit_dict: dict[str, str] = {} + homekit_dict: dict[str, dict[str, str]] = {} for domain in sorted(integrations): integration = integrations[domain] @@ -42,7 +42,10 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str: ) break - homekit_dict[model] = domain + homekit_dict[model] = { + "integration": domain, + "iot_class": integration.manifest["iot_class"], + } # HomeKit models are matched on starting string, make sure none overlap. warned = set()