diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 088e46aa1cb..df7eec71bd8 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, + HomeKitDiscoveredIntegration, 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, HomeKitDiscoveredIntegration], 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 @@ -437,27 +459,15 @@ class ZeroconfDiscovery: # 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 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. + if not is_homekit_paired(props) and not homekit_model.always_discover: + # If the device is paired with HomeKit we must send on + # the update to homekit_controller so it can see when + # the 'c#' field is updated. This is used to detect + # when the device has been reset or updated. # - # 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. - # - # 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 + # If the device is not paired and we should not always + # discover it, we can stop here. + return match_data: dict[str, str] = {} for key in LOWER_MATCH_ATTRS: @@ -495,8 +505,8 @@ class ZeroconfDiscovery: def async_get_homekit_discovery_domain( - homekit_models: dict[str, str], props: dict[str, Any] -) -> str | None: + homekit_models: dict[str, HomeKitDiscoveredIntegration], props: dict[str, Any] +) -> HomeKitDiscoveredIntegration | 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..ae9668f3729 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": { + "always_discover": True, + "domain": "roku", + }, + "3820X": { + "always_discover": True, + "domain": "roku", + }, + "4660X": { + "always_discover": True, + "domain": "roku", + }, + "7820X": { + "always_discover": True, + "domain": "roku", + }, + "819LMB": { + "always_discover": True, + "domain": "myq", + }, + "AC02": { + "always_discover": True, + "domain": "tado", + }, + "Abode": { + "always_discover": True, + "domain": "abode", + }, + "BSB002": { + "always_discover": False, + "domain": "hue", + }, + "C105X": { + "always_discover": True, + "domain": "roku", + }, + "C135X": { + "always_discover": True, + "domain": "roku", + }, + "EB-*": { + "always_discover": True, + "domain": "ecobee", + }, + "Escea": { + "always_discover": False, + "domain": "escea", + }, + "HHKBridge*": { + "always_discover": True, + "domain": "hive", + }, + "Healty Home Coach": { + "always_discover": True, + "domain": "netatmo", + }, + "Iota": { + "always_discover": True, + "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 = { diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 523e2db5159..36854388e91 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 HomeKitDiscoveredIntegration: + """HomeKit model.""" + + domain: str + always_discover: bool + + class Manifest(TypedDict, total=False): """Integration manifest. @@ -410,10 +419,30 @@ async def async_get_usb(hass: HomeAssistant) -> list[USBMatcher]: return usb -async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: - """Return cached list of homekit models.""" +def homekit_always_discover(iot_class: str | None) -> bool: + """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) for integration in integrations.values(): @@ -424,7 +453,10 @@ async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: ): continue for model in integration.homekit["models"]: - homekit[model] = integration.domain + homekit[model] = HomeKitDiscoveredIntegration( + integration.domain, + homekit_always_discover(integration.iot_class), + ) return homekit diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 76a7be45c34..bb6a84e1e2c 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -3,7 +3,10 @@ from __future__ import annotations 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 .serializer import format_python_namespace @@ -12,7 +15,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 +45,12 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str: ) 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. warned = set() diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index eceb54c52de..3ec7487012d 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -542,7 +542,7 @@ async def test_homekit_match_partial_space(hass, mock_async_zeroconf): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"LIFX": "lifx"}, + {"LIFX": {"domain": "lifx", "always_discover": True}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -578,7 +578,7 @@ async def test_device_with_invalid_name(hass, mock_async_zeroconf, caplog): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"LIFX": "lifx"}, + {"LIFX": {"domain": "lifx", "always_discover": True}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -609,7 +609,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"Smart Bridge": "lutron_caseta"}, + {"Smart Bridge": {"domain": "lutron_caseta", "always_discover": False}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -638,7 +638,11 @@ async def test_homekit_match_partial_fnmatch(hass, mock_async_zeroconf): zc_gen.ZEROCONF, {"_hap._tcp.local.": [{"domain": "homekit_controller"}]}, 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" ) as mock_config_flow, patch.object( zeroconf, @@ -667,7 +671,7 @@ async def test_homekit_match_full(hass, mock_async_zeroconf): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"BSB002": "hue"}, + {"BSB002": {"domain": "hue", "always_discover": False}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -698,7 +702,10 @@ async def test_homekit_already_paired(hass, mock_async_zeroconf): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"AC02": "tado", "tado": "tado"}, + { + "AC02": {"domain": "tado", "always_discover": True}, + "tado": {"domain": "tado", "always_discover": True}, + }, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -730,7 +737,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf): clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"Smart Bridge": "lutron_caseta"}, + {"Smart Bridge": {"domain": "lutron_caseta", "always_discover": False}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -794,7 +801,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"Rachio": "rachio"}, + {"Rachio": {"domain": "rachio", "always_discover": True}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -834,7 +841,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( clear=True, ), patch.dict( zc_gen.HOMEKIT, - {"iSmartGate": "gogogate2"}, + {"iSmartGate": {"domain": "gogogate2", "always_discover": True}}, clear=True, ), patch.object( hass.config_entries.flow, "async_init" diff --git a/tests/test_loader.py b/tests/test_loader.py index beb50e79bf9..62b87aff6b1 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -623,8 +623,8 @@ async def test_get_homekit(hass: HomeAssistant) -> None: "test_2": test_2_integration, } homekit = await loader.async_get_homekit(hass) - assert homekit["test_1"] == "test_1" - assert homekit["test_2"] == "test_2" + assert homekit["test_1"] == loader.HomeKitDiscoveredIntegration("test_1", True) + assert homekit["test_2"] == loader.HomeKitDiscoveredIntegration("test_2", True) async def test_get_ssdp(hass: HomeAssistant) -> None: