Break apart zeroconf integration to prepare for WebSocket API (#143490)

This commit is contained in:
J. Nick Koston 2025-04-23 07:47:37 -10:00 committed by GitHub
parent 65db3c1164
commit 36081c69e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 470 additions and 436 deletions

View File

@ -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(

View File

@ -0,0 +1,5 @@
"""Zeroconf constants."""
DOMAIN = "zeroconf"
ZEROCONF_TYPE = "_home-assistant._tcp.local."

View File

@ -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,
)

View File

@ -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"),
),
):

View File

@ -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