mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 09:17:10 +00:00
Break apart zeroconf integration to prepare for WebSocket API (#143490)
This commit is contained in:
parent
65db3c1164
commit
36081c69e0
@ -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(
|
||||
|
5
homeassistant/components/zeroconf/const.py
Normal file
5
homeassistant/components/zeroconf/const.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Zeroconf constants."""
|
||||
|
||||
DOMAIN = "zeroconf"
|
||||
|
||||
ZEROCONF_TYPE = "_home-assistant._tcp.local."
|
385
homeassistant/components/zeroconf/discovery.py
Normal file
385
homeassistant/components/zeroconf/discovery.py
Normal 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,
|
||||
)
|
@ -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"),
|
||||
),
|
||||
):
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user