From 36081c69e077638ef424d0c03cbc4fe7b9e0e1c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Apr 2025 07:47:37 -1000 Subject: [PATCH] Break apart zeroconf integration to prepare for WebSocket API (#143490) --- homeassistant/components/zeroconf/__init__.py | 390 +----------------- homeassistant/components/zeroconf/const.py | 5 + .../components/zeroconf/discovery.py | 385 +++++++++++++++++ tests/components/zeroconf/test_init.py | 121 +++--- tests/conftest.py | 5 +- 5 files changed, 470 insertions(+), 436 deletions(-) create mode 100644 homeassistant/components/zeroconf/const.py create mode 100644 homeassistant/components/zeroconf/discovery.py diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 86f8dbca792..383276d645f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,26 +2,17 @@ from __future__ import annotations -import contextlib from contextlib import suppress -from fnmatch import translate -from functools import lru_cache, partial +from functools import partial from ipaddress import IPv4Address, IPv6Address import logging -import re import sys -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, cast import voluptuous as vol -from zeroconf import ( - BadTypeInNameException, - InterfaceChoice, - IPVersion, - ServiceStateChange, -) -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo +from zeroconf import InterfaceChoice, IPVersion +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries from homeassistant.components import network from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, @@ -29,55 +20,40 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers import config_validation as cv, instance_id from homeassistant.helpers.deprecation import ( DeprecatedConstant, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) -from homeassistant.helpers.discovery_flow import DiscoveryKey -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, ZeroconfServiceInfo as _ZeroconfServiceInfo, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import ( - HomeKitDiscoveredIntegration, - ZeroconfMatcher, - async_get_homekit, - async_get_zeroconf, - bind_hass, -) +from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from homeassistant.setup import async_when_setup_or_start +from .const import DOMAIN, ZEROCONF_TYPE +from .discovery import ( # noqa: F401 + DATA_DISCOVERY, + ZeroconfDiscovery, + build_homekit_model_lookups, + info_from_service, +) from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) -DOMAIN = "zeroconf" - -ZEROCONF_TYPE = "_home-assistant._tcp.local." -HOMEKIT_TYPES = [ - "_hap._tcp.local.", - # Thread based devices - "_hap._udp.local.", -] -_HOMEKIT_MODEL_SPLITS = (None, " ", "-") - CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True -HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL_LOWER = "md" -HOMEKIT_MODEL_UPPER = "MD" - # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -85,10 +61,6 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 -ATTR_DOMAIN: Final = "domain" -ATTR_NAME: Final = "name" -ATTR_PROPERTIES: Final = "properties" - # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] _DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( _ATTR_PROPERTIES_ID, @@ -214,7 +186,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) - homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups( homekit_models ) discovery = ZeroconfDiscovery( @@ -225,6 +197,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: homekit_model_matchers, ) await discovery.async_setup() + hass.data[DATA_DISCOVERY] = discovery async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. @@ -243,25 +216,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration], -) -> tuple[ - dict[str, HomeKitDiscoveredIntegration], - dict[re.Pattern, HomeKitDiscoveredIntegration], -]: - """Build lookups for homekit models.""" - homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} - - for model, discovery in homekit_models.items(): - if "*" in model or "?" in model or "[" in model: - homekit_model_matchers[_compile_fnmatch(model)] = discovery - else: - homekit_model_lookup[model] = discovery - - return homekit_model_lookup, homekit_model_matchers - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -315,299 +269,6 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: - """Check a matcher to ensure all values in props.""" - for key, value in matcher.items(): - prop_val = props.get(key) - if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): - return False - return True - - -def is_homekit_paired(props: dict[str, Any]) -> bool: - """Check properties to see if a device is homekit paired.""" - if HOMEKIT_PAIRED_STATUS_FLAG not in props: - return False - with contextlib.suppress(ValueError): - # 0 means paired and not discoverable by iOS clients) - return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 - # If we cannot tell, we assume its not paired - return False - - -class ZeroconfDiscovery: - """Discovery via zeroconf.""" - - def __init__( - self, - hass: HomeAssistant, - zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[ZeroconfMatcher]], - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ) -> None: - """Init discovery.""" - self.hass = hass - self.zeroconf = zeroconf - self.zeroconf_types = zeroconf_types - self.homekit_model_lookups = homekit_model_lookups - self.homekit_model_matchers = homekit_model_matchers - self.async_service_browser: AsyncServiceBrowser | None = None - - async def async_setup(self) -> None: - """Start discovery.""" - types = list(self.zeroconf_types) - # We want to make sure we know about other HomeAssistant - # instances as soon as possible to avoid name conflicts - # so we always browse for ZEROCONF_TYPE - types.extend( - hk_type - for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) - if hk_type not in self.zeroconf_types - ) - _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = AsyncServiceBrowser( - self.zeroconf, types, handlers=[self.async_service_update] - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - async def async_stop(self) -> None: - """Cancel the service browser and stop processing the queue.""" - if self.async_service_browser: - await self.async_service_browser.async_cancel() - - @callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1: - continue - _type = discovery_key.key[0] - name = discovery_key.key[1] - _LOGGER.debug("Rediscover service %s.%s", _type, name) - self._async_service_update(self.zeroconf, _type, name) - - def _async_dismiss_discoveries(self, name: str) -> None: - """Dismiss all discoveries for the given name.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - _ZeroconfServiceInfo, - lambda service_info: bool(service_info.name == name), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - @callback - def async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - state_change: ServiceStateChange, - ) -> None: - """Service state changed.""" - _LOGGER.debug( - "service_update: type=%s name=%s state_change=%s", - service_type, - name, - state_change, - ) - - if state_change is ServiceStateChange.Removed: - self._async_dismiss_discoveries(name) - return - - self._async_service_update(zeroconf, service_type, name) - - @callback - def _async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - ) -> None: - """Service state added or changed.""" - try: - async_service_info = AsyncServiceInfo(service_type, name) - except BadTypeInNameException as ex: - # Some devices broadcast a name that is not a valid DNS name - # 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 - - if async_service_info.load_from_cache(zeroconf): - self._async_process_service_update(async_service_info, service_type, name) - else: - self.hass.async_create_background_task( - self._async_lookup_and_process_service_update( - zeroconf, async_service_info, service_type, name - ), - name=f"zeroconf lookup {name}.{service_type}", - ) - - 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 | None] = info.properties - discovery_key = DiscoveryKey( - domain=DOMAIN, - key=(info.type, info.name), - version=1, - ) - domain = None - - # If we can handle it as a HomeKit discovery, we do that here. - if service_type in HOMEKIT_TYPES and ( - homekit_discovery := async_get_homekit_discovery( - self.homekit_model_lookups, self.homekit_model_matchers, props - ) - ): - domain = homekit_discovery.domain - discovery_flow.async_create_flow( - self.hass, - homekit_discovery.domain, - {"source": config_entries.SOURCE_HOMEKIT}, - info, - discovery_key=discovery_key, - ) - # 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 not is_homekit_paired(props) and not homekit_discovery.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. - # - # If the device is not paired and we should not always - # discover it, we can stop here. - return - - if not (matchers := self.zeroconf_types.get(service_type)): - return - - # Not all homekit types are currently used for discovery - # so not all service type exist in zeroconf_types - for matcher in matchers: - if len(matcher) > 1: - if ATTR_NAME in matcher and not _memorized_fnmatch( - info.name.lower(), matcher[ATTR_NAME] - ): - continue - if ATTR_PROPERTIES in matcher and not _match_against_props( - matcher[ATTR_PROPERTIES], props - ): - continue - - matcher_domain = matcher[ATTR_DOMAIN] - # Create a type annotated regular dict since this is a hot path and creating - # a regular dict is slightly cheaper than calling ConfigFlowContext - context: config_entries.ConfigFlowContext = { - "source": config_entries.SOURCE_ZEROCONF, - } - if domain: - # Domain of integration that offers alternative API to handle - # this device. - context["alternative_domain"] = domain - - discovery_flow.async_create_flow( - self.hass, - matcher_domain, - context, - info, - discovery_key=discovery_key, - ) - - -def async_get_homekit_discovery( - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - props: dict[str, Any], -) -> HomeKitDiscoveredIntegration | None: - """Handle a HomeKit discovery. - - Return the domain to forward the discovery data to - """ - if not ( - model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) - ) or not isinstance(model, str): - return None - - for split_str in _HOMEKIT_MODEL_SPLITS: - key = (model.split(split_str))[0] if split_str else model - if discovery := homekit_model_lookups.get(key): - return discovery - - for pattern, discovery in homekit_model_matchers.items(): - if pattern.match(model): - return discovery - - return None - - -def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: - """Return prepared info from mDNS entries.""" - # 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 - if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): - return None - if TYPE_CHECKING: - ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) - else: - ip_addresses = maybe_ip_addresses - ip_address: IPv4Address | IPv6Address | None = None - for ip_addr in ip_addresses: - if not ip_addr.is_link_local and not ip_addr.is_unspecified: - ip_address = ip_addr - break - if not ip_address: - return None - - if TYPE_CHECKING: - assert service.server is not None, ( - "server cannot be none if there are addresses" - ) - return _ZeroconfServiceInfo( - ip_address=ip_address, - ip_addresses=ip_addresses, - port=service.port, - hostname=service.server, - type=service.type, - name=service.name, - properties=service.decoded_properties, - ) - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" @@ -644,27 +305,6 @@ def _truncate_location_name_to_valid(location_name: str) -> str: return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore") -@lru_cache(maxsize=4096, typed=True) -def _compile_fnmatch(pattern: str) -> re.Pattern: - """Compile a fnmatch pattern.""" - return re.compile(translate(pattern)) - - -@lru_cache(maxsize=1024, typed=True) -def _memorized_fnmatch(name: str, pattern: str) -> bool: - """Memorized version of fnmatch that has a larger lru_cache. - - The default version of fnmatch only has a lru_cache of 256 entries. - With many devices we quickly reach that limit and end up compiling - the same pattern over and over again. - - Zeroconf has its own memorized fnmatch with its own lru_cache - since the data is going to be relatively the same - since the devices will not change frequently - """ - return bool(_compile_fnmatch(pattern).match(name)) - - # These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) __dir__ = partial( diff --git a/homeassistant/components/zeroconf/const.py b/homeassistant/components/zeroconf/const.py new file mode 100644 index 00000000000..3a99a6758ca --- /dev/null +++ b/homeassistant/components/zeroconf/const.py @@ -0,0 +1,5 @@ +"""Zeroconf constants.""" + +DOMAIN = "zeroconf" + +ZEROCONF_TYPE = "_home-assistant._tcp.local." diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py new file mode 100644 index 00000000000..b2e06c19948 --- /dev/null +++ b/homeassistant/components/zeroconf/discovery.py @@ -0,0 +1,385 @@ +"""Zeroconf discovery for Home Assistant.""" + +from __future__ import annotations + +import contextlib +from fnmatch import translate +from functools import lru_cache +from ipaddress import IPv4Address, IPv6Address +import logging +import re +from typing import TYPE_CHECKING, Any, Final, cast + +from zeroconf import BadTypeInNameException, IPVersion, ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ( + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) +from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +if TYPE_CHECKING: + from .models import HaZeroconf + +_LOGGER = logging.getLogger(__name__) + +ZEROCONF_TYPE = "_home-assistant._tcp.local." +HOMEKIT_TYPES = [ + "_hap._tcp.local.", + # Thread based devices + "_hap._udp.local.", +] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") + + +HOMEKIT_PAIRED_STATUS_FLAG = "sf" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" + +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" +ATTR_PROPERTIES: Final = "properties" + + +DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") + + +def build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration], +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + +@lru_cache(maxsize=4096, typed=True) +def _compile_fnmatch(pattern: str) -> re.Pattern: + """Compile a fnmatch pattern.""" + return re.compile(translate(pattern)) + + +@lru_cache(maxsize=1024, typed=True) +def _memorized_fnmatch(name: str, pattern: str) -> bool: + """Memorized version of fnmatch that has a larger lru_cache. + + The default version of fnmatch only has a lru_cache of 256 entries. + With many devices we quickly reach that limit and end up compiling + the same pattern over and over again. + + Zeroconf has its own memorized fnmatch with its own lru_cache + since the data is going to be relatively the same + since the devices will not change frequently + """ + return bool(_compile_fnmatch(pattern).match(name)) + + +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: + """Check a matcher to ensure all values in props.""" + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True + + +def is_homekit_paired(props: dict[str, Any]) -> bool: + """Check properties to see if a device is homekit paired.""" + if HOMEKIT_PAIRED_STATUS_FLAG not in props: + return False + with contextlib.suppress(ValueError): + # 0 means paired and not discoverable by iOS clients) + return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 + # If we cannot tell, we assume its not paired + return False + + +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], +) -> HomeKitDiscoveredIntegration | None: + """Handle a HomeKit discovery. + + Return the domain to forward the discovery data to + """ + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): + return None + + for split_str in _HOMEKIT_MODEL_SPLITS: + key = (model.split(split_str))[0] if split_str else model + if discovery := homekit_model_lookups.get(key): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery + + return None + + +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: + """Return prepared info from mDNS entries.""" + # 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 + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + return None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + ip_address = ip_addr + break + if not ip_address: + return None + + if TYPE_CHECKING: + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) + return _ZeroconfServiceInfo( + ip_address=ip_address, + ip_addresses=ip_addresses, + port=service.port, + hostname=service.server, + type=service.type, + name=service.name, + properties=service.decoded_properties, + ) + + +class ZeroconfDiscovery: + """Discovery via zeroconf.""" + + def __init__( + self, + hass: HomeAssistant, + zeroconf: HaZeroconf, + zeroconf_types: dict[str, list[ZeroconfMatcher]], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + ) -> None: + """Init discovery.""" + self.hass = hass + self.zeroconf = zeroconf + self.zeroconf_types = zeroconf_types + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.async_service_browser: AsyncServiceBrowser | None = None + + async def async_setup(self) -> None: + """Start discovery.""" + types = list(self.zeroconf_types) + # We want to make sure we know about other HomeAssistant + # instances as soon as possible to avoid name conflicts + # so we always browse for ZEROCONF_TYPE + types.extend( + hk_type + for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) + if hk_type not in self.zeroconf_types + ) + _LOGGER.debug("Starting Zeroconf browser for: %s", types) + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + async def async_stop(self) -> None: + """Cancel the service browser and stop processing the queue.""" + if self.async_service_browser: + await self.async_service_browser.async_cancel() + + @callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1: + continue + _type = discovery_key.key[0] + name = discovery_key.key[1] + _LOGGER.debug("Rediscover service %s.%s", _type, name) + self._async_service_update(self.zeroconf, _type, name) + + def _async_dismiss_discoveries(self, name: str) -> None: + """Dismiss all discoveries for the given name.""" + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + _ZeroconfServiceInfo, + lambda service_info: bool(service_info.name == name), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + @callback + def async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + """Service state changed.""" + _LOGGER.debug( + "service_update: type=%s name=%s state_change=%s", + service_type, + name, + state_change, + ) + + if state_change is ServiceStateChange.Removed: + self._async_dismiss_discoveries(name) + return + + self._async_service_update(zeroconf, service_type, name) + + @callback + def _async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + ) -> None: + """Service state added or changed.""" + try: + async_service_info = AsyncServiceInfo(service_type, name) + except BadTypeInNameException as ex: + # Some devices broadcast a name that is not a valid DNS name + # 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 + + if async_service_info.load_from_cache(zeroconf): + self._async_process_service_update(async_service_info, service_type, name) + else: + self.hass.async_create_background_task( + self._async_lookup_and_process_service_update( + zeroconf, async_service_info, service_type, name + ), + name=f"zeroconf lookup {name}.{service_type}", + ) + + 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 | None] = info.properties + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=(info.type, info.name), + version=1, + ) + domain = None + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type in HOMEKIT_TYPES and ( + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props + ) + ): + domain = homekit_discovery.domain + discovery_flow.async_create_flow( + self.hass, + homekit_discovery.domain, + {"source": config_entries.SOURCE_HOMEKIT}, + info, + discovery_key=discovery_key, + ) + # 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 not is_homekit_paired(props) and not homekit_discovery.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. + # + # If the device is not paired and we should not always + # discover it, we can stop here. + return + + if not (matchers := self.zeroconf_types.get(service_type)): + return + + # Not all homekit types are currently used for discovery + # so not all service type exist in zeroconf_types + for matcher in matchers: + if len(matcher) > 1: + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): + continue + + matcher_domain = matcher[ATTR_DOMAIN] + # Create a type annotated regular dict since this is a hot path and creating + # a regular dict is slightly cheaper than calling ConfigFlowContext + context: config_entries.ConfigFlowContext = { + "source": config_entries.SOURCE_ZEROCONF, + } + if domain: + # Domain of integration that offers alternative API to handle + # this device. + context["alternative_domain"] = domain + + discovery_flow.async_create_flow( + self.hass, + matcher_domain, + context, + info, + discovery_key=discovery_key, + ) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 56262600511..847727796bb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -14,6 +14,7 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import discovery from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_CLOSE, @@ -181,10 +182,10 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -214,7 +215,7 @@ async def test_setup_with_overly_long_url_and_name( """Test we still setup with long urls and names.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -240,7 +241,7 @@ async def test_setup_with_overly_long_url_and_name( ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -258,9 +259,9 @@ async def test_setup_with_defaults( """Test default interface config.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -302,10 +303,10 @@ async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -351,10 +352,10 @@ async def test_zeroconf_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), ), ): @@ -392,10 +393,10 @@ async def test_zeroconf_match_model(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), ), ): @@ -433,10 +434,10 @@ async def test_zeroconf_match_manufacturer_not_present(hass: HomeAssistant) -> N ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), ), ): @@ -469,10 +470,10 @@ async def test_zeroconf_no_match(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -509,10 +510,10 @@ async def test_zeroconf_no_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), ), ): @@ -540,14 +541,14 @@ async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("LIFX bulb", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -588,14 +589,14 @@ async def test_device_with_invalid_name( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=BadTypeInNameException, ), ): @@ -624,14 +625,14 @@ async def test_homekit_match_partial_dash(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED ), @@ -662,14 +663,14 @@ async def test_homekit_match_partial_fnmatch(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("YLDP13YL", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -698,14 +699,14 @@ async def test_homekit_match_full(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -737,14 +738,14 @@ async def test_homekit_already_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("tado", HOMEKIT_STATUS_PAIRED), ), ): @@ -774,14 +775,14 @@ async def test_homekit_invalid_paring_status(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"), ), ): @@ -805,10 +806,10 @@ async def test_homekit_not_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ), @@ -847,14 +848,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -892,14 +893,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("iSmartGate", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -1053,9 +1054,9 @@ async def test_removed_ignored(hass: HomeAssistant) -> None: ) with ( - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ) as mock_service_info, ): @@ -1088,13 +1089,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1176,13 +1177,13 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1210,13 +1211,13 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1261,13 +1262,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1290,13 +1291,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1319,13 +1320,13 @@ async def test_async_detect_interfaces_explicitly_before_setup( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1359,14 +1360,14 @@ async def test_setup_with_disallowed_characters_in_local_name( """Test we still setup with disallowed characters in the location name.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch.object( hass.config, "location_name", "My.House", ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -1422,10 +1423,10 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ) as mock_async_progress_by_init_data_type, patch.object(hass.config_entries.flow, "async_abort") as mock_async_abort, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock + discovery, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1545,10 +1546,10 @@ async def test_zeroconf_rediscover( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1665,10 +1666,10 @@ async def test_zeroconf_rediscover_no_match( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): diff --git a/tests/conftest.py b/tests/conftest.py index a34c20a1445..5e1a97863f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1346,7 +1346,10 @@ def mock_zeroconf() -> Generator[MagicMock]: with ( patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True) as mock_zc, - patch("homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceBrowser", + autospec=True, + ), ): zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work