1 """Support for exposing Home Assistant via Zeroconf."""
3 from __future__
import annotations
6 from contextlib
import suppress
7 from dataclasses
import dataclass
8 from fnmatch
import translate
9 from functools
import lru_cache
10 from ipaddress
import IPv4Address, IPv6Address
14 from typing
import TYPE_CHECKING, Any, Final, cast
16 import voluptuous
as vol
17 from zeroconf
import (
18 BadTypeInNameException,
23 from zeroconf.asyncio
import AsyncServiceBrowser, AsyncServiceInfo
25 from homeassistant
import config_entries
28 EVENT_HOMEASSISTANT_CLOSE,
29 EVENT_HOMEASSISTANT_STOP,
41 HomeKitDiscoveredIntegration,
49 from .models
import HaAsyncZeroconf, HaZeroconf
50 from .usage
import install_multiple_zeroconf_catcher
52 _LOGGER = logging.getLogger(__name__)
56 ZEROCONF_TYPE =
"_home-assistant._tcp.local."
62 _HOMEKIT_MODEL_SPLITS = (
None,
" ",
"-")
65 CONF_DEFAULT_INTERFACE =
"default_interface"
67 DEFAULT_DEFAULT_INTERFACE =
True
70 HOMEKIT_PAIRED_STATUS_FLAG =
"sf"
71 HOMEKIT_MODEL_LOWER =
"md"
72 HOMEKIT_MODEL_UPPER =
"MD"
76 MAX_PROPERTY_VALUE_LEN = 230
81 ATTR_DOMAIN: Final =
"domain"
82 ATTR_NAME: Final =
"name"
83 ATTR_PROPERTIES: Final =
"properties"
86 ATTR_PROPERTIES_ID: Final =
"id"
88 CONFIG_SCHEMA = vol.Schema(
91 cv.deprecated(CONF_DEFAULT_INTERFACE),
92 cv.deprecated(CONF_IPV6),
95 vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean,
96 vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
101 extra=vol.ALLOW_EXTRA,
105 @dataclass(slots=True)
107 """Prepared info from mDNS entries.
109 The ip_address is the most recently updated address
110 that is not a link local or unspecified address.
112 The ip_addresses are all addresses in order of most
113 recently updated to least recently updated.
115 The host is the string representation of the ip_address.
117 The addresses are the string representations of the
120 It is recommended to use the ip_address to determine
121 the address to connect to as it will be the most
122 recently updated address that is not a link local
123 or unspecified address.
126 ip_address: IPv4Address | IPv6Address
127 ip_addresses: list[IPv4Address | IPv6Address]
132 properties: dict[str, Any]
136 """Return the host."""
137 return str(self.ip_address)
141 """Return the addresses."""
142 return [
str(ip_address)
for ip_address
in self.ip_addresses]
147 """Zeroconf instance to be shared with other integrations that use it."""
153 """Zeroconf instance to be shared with other integrations that use it."""
158 if DOMAIN
in hass.data:
159 return cast(HaAsyncZeroconf, hass.data[DOMAIN])
161 logging.getLogger(
"zeroconf").setLevel(logging.NOTSET)
168 async
def _async_stop_zeroconf(_event: Event) ->
None:
170 await aio_zc.ha_async_close()
174 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_zeroconf)
175 hass.data[DOMAIN] = aio_zc
182 """Return true for platforms not supporting IP_ADD_MEMBERSHIP on an AF_INET6 socket.
184 Zeroconf only supports a single listen socket at this time.
186 return not sys.platform.startswith(
"freebsd")
and not sys.platform.startswith(
191 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
192 """Set up Zeroconf and make Home Assistant discoverable."""
193 zc_args: dict = {
"ip_version": IPVersion.V4Only}
195 adapters = await network.async_get_adapters(hass)
199 if any(adapter[
"enabled"]
and adapter[
"ipv6"]
for adapter
in adapters):
201 zc_args[
"ip_version"] = IPVersion.All
202 elif not any(adapter[
"enabled"]
and adapter[
"ipv4"]
for adapter
in adapters):
203 zc_args[
"ip_version"] = IPVersion.V6Only
206 if not ipv6
and network.async_only_default_interface_enabled(adapters):
207 zc_args[
"interfaces"] = InterfaceChoice.Default
209 zc_args[
"interfaces"] = [
211 for source_ip
in await network.async_get_enabled_source_ips(hass)
212 if not source_ip.is_loopback
213 and not (isinstance(source_ip, IPv6Address)
and source_ip.is_global)
215 isinstance(source_ip, IPv6Address)
216 and zc_args[
"ip_version"] == IPVersion.V4Only
219 isinstance(source_ip, IPv4Address)
220 and zc_args[
"ip_version"] == IPVersion.V6Only
225 zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
235 homekit_model_lookup,
236 homekit_model_matchers,
238 await discovery.async_setup()
240 async
def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) ->
None:
241 """Expose Home Assistant on zeroconf when it starts.
243 Wait till started or otherwise HTTP is not up and running.
245 uuid = await instance_id.async_get(hass)
248 async
def _async_zeroconf_hass_stop(_event: Event) ->
None:
249 await discovery.async_stop()
251 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_zeroconf_hass_stop)
258 homekit_models: dict[str, HomeKitDiscoveredIntegration],
260 dict[str, HomeKitDiscoveredIntegration],
261 dict[re.Pattern, HomeKitDiscoveredIntegration],
263 """Build lookups for homekit models."""
264 homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {}
265 homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {}
267 for model, discovery
in homekit_models.items():
268 if "*" in model
or "?" in model
or "[" in model:
271 homekit_model_lookup[model] = discovery
273 return homekit_model_lookup, homekit_model_matchers
277 """Filter disallowed characters from a string.
279 . is a reversed character for zeroconf.
281 return name.replace(
".",
" ")
285 hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str
293 "location_name": valid_location_name,
295 "version": __version__,
301 "requires_api_password":
True,
305 with suppress(NoURLAvailableError):
306 params[
"external_url"] =
get_url(hass, allow_internal=
False)
308 with suppress(NoURLAvailableError):
309 params[
"internal_url"] =
get_url(hass, allow_external=
False)
312 params[
"base_url"] = params[
"external_url"]
or params[
"internal_url"]
316 info = AsyncServiceInfo(
318 name=f
"{valid_location_name}.{ZEROCONF_TYPE}",
319 server=f
"{uuid}.local.",
320 parsed_addresses=await network.async_get_announce_addresses(hass),
321 port=hass.http.server_port,
325 _LOGGER.info(
"Starting Zeroconf broadcast")
326 await aio_zc.async_register_service(info, allow_name_change=
True)
330 """Check a matcher to ensure all values in props."""
331 for key, value
in matcher.items():
332 prop_val = props.get(key)
339 """Check properties to see if a device is homekit paired."""
340 if HOMEKIT_PAIRED_STATUS_FLAG
not in props:
342 with contextlib.suppress(ValueError):
344 return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0
350 """Discovery via zeroconf."""
355 zeroconf: HaZeroconf,
356 zeroconf_types: dict[str, list[ZeroconfMatcher]],
357 homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
358 homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
360 """Init discovery."""
369 """Start discovery."""
376 for hk_type
in (ZEROCONF_TYPE, *HOMEKIT_TYPES)
379 _LOGGER.debug(
"Starting Zeroconf browser for: %s", types)
386 config_entries.signal_discovered_config_entry_removed(DOMAIN),
391 """Cancel the service browser and stop processing the queue."""
398 entry: config_entries.ConfigEntry,
400 """Handle config entry changes."""
401 for discovery_key
in entry.discovery_keys[DOMAIN]:
402 if discovery_key.version != 1:
404 _type = discovery_key.key[0]
405 name = discovery_key.key[1]
406 _LOGGER.debug(
"Rediscover service %s.%s", _type, name)
410 """Dismiss all discoveries for the given name."""
411 for flow
in self.
hasshass.config_entries.flow.async_progress_by_init_data_type(
413 lambda service_info: bool(service_info.name == name),
415 self.
hasshass.config_entries.flow.async_abort(flow[
"flow_id"])
420 zeroconf: HaZeroconf,
423 state_change: ServiceStateChange,
425 """Service state changed."""
427 "service_update: type=%s name=%s state_change=%s",
433 if state_change
is ServiceStateChange.Removed:
442 zeroconf: HaZeroconf,
446 """Service state added or changed."""
448 async_service_info = AsyncServiceInfo(service_type, name)
449 except BadTypeInNameException
as ex:
452 _LOGGER.debug(
"Bad name in zeroconf record: %s: %s", name, ex)
455 if async_service_info.load_from_cache(zeroconf):
458 self.
hasshass.async_create_background_task(
460 zeroconf, async_service_info, service_type, name
462 name=f
"zeroconf lookup {name}.{service_type}",
467 zeroconf: HaZeroconf,
468 async_service_info: AsyncServiceInfo,
472 """Update and process a zeroconf update."""
473 await async_service_info.async_request(zeroconf, 3000)
478 self, async_service_info: AsyncServiceInfo, service_type: str, name: str
480 """Process a zeroconf update."""
484 _LOGGER.debug(
"Failed to get addresses for device %s", name)
486 _LOGGER.debug(
"Discovered new device %s %s", name, info)
487 props: dict[str, str |
None] = info.properties
490 key=(info.type, info.name),
496 if service_type
in HOMEKIT_TYPES
and (
501 domain = homekit_discovery.domain
502 discovery_flow.async_create_flow(
504 homekit_discovery.domain,
505 {
"source": config_entries.SOURCE_HOMEKIT},
507 discovery_key=discovery_key,
531 for matcher
in matchers:
534 info.name.lower(), matcher[ATTR_NAME]
538 matcher[ATTR_PROPERTIES], props
542 matcher_domain = matcher[ATTR_DOMAIN]
546 "source": config_entries.SOURCE_ZEROCONF,
551 context[
"alternative_domain"] = domain
553 discovery_flow.async_create_flow(
558 discovery_key=discovery_key,
563 homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
564 homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
565 props: dict[str, Any],
566 ) -> HomeKitDiscoveredIntegration |
None:
567 """Handle a HomeKit discovery.
569 Return the domain to forward the discovery data to
572 model := props.get(HOMEKIT_MODEL_LOWER)
or props.get(HOMEKIT_MODEL_UPPER)
573 )
or not isinstance(model, str):
576 for split_str
in _HOMEKIT_MODEL_SPLITS:
577 key = (model.split(split_str))[0]
if split_str
else model
578 if discovery := homekit_model_lookups.get(key):
581 for pattern, discovery
in homekit_model_matchers.items():
582 if pattern.match(model):
589 """Return prepared info from mDNS entries."""
593 if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)):
596 ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses)
598 ip_addresses = maybe_ip_addresses
599 ip_address: IPv4Address | IPv6Address |
None =
None
600 for ip_addr
in ip_addresses:
601 if not ip_addr.is_link_local
and not ip_addr.is_unspecified:
609 service.server
is not None
610 ),
"server cannot be none if there are addresses"
612 ip_address=ip_address,
613 ip_addresses=ip_addresses,
615 hostname=service.server,
618 properties=service.decoded_properties,
623 """Suppress any properties that will cause zeroconf to fail to startup."""
625 for prop, prop_value
in properties.items():
626 if not isinstance(prop_value, str):
629 if len(prop_value.encode(
"utf-8")) > MAX_PROPERTY_VALUE_LEN:
632 "The property '%s' was suppressed because it is longer than the"
633 " maximum length of %d bytes: %s"
636 MAX_PROPERTY_VALUE_LEN,
639 properties[prop] =
""
643 """Truncate or return the location name usable for zeroconf."""
644 if len(location_name.encode(
"utf-8")) < MAX_NAME_LEN:
649 "The location name was truncated because it is longer than the maximum"
650 " length of %d bytes: %s"
655 return location_name.encode(
"utf-8")[:MAX_NAME_LEN].decode(
"utf-8",
"ignore")
658 @lru_cache(maxsize=4096, typed=True)
660 """Compile a fnmatch pattern."""
661 return re.compile(translate(pattern))
664 @lru_cache(maxsize=1024, typed=True)
666 """Memorized version of fnmatch that has a larger lru_cache.
668 The default version of fnmatch only has a lru_cache of 256 entries.
669 With many devices we quickly reach that limit and end up compiling
670 the same pattern over and over again.
672 Zeroconf has its own memorized fnmatch with its own lru_cache
673 since the data is going to be relatively the same
674 since the devices will not change frequently
None _async_lookup_and_process_service_update(self, HaZeroconf zeroconf, AsyncServiceInfo async_service_info, str service_type, str name)
None _async_dismiss_discoveries(self, str name)
None __init__(self, HomeAssistant hass, HaZeroconf zeroconf, dict[str, list[ZeroconfMatcher]] zeroconf_types, dict[str, HomeKitDiscoveredIntegration] homekit_model_lookups, dict[re.Pattern, HomeKitDiscoveredIntegration] homekit_model_matchers)
None async_service_update(self, HaZeroconf zeroconf, str service_type, str name, ServiceStateChange state_change)
None _async_process_service_update(self, AsyncServiceInfo async_service_info, str service_type, str name)
None _handle_config_entry_removed(self, config_entries.ConfigEntry entry)
None _async_service_update(self, HaZeroconf zeroconf, str service_type, str name)
list[str] addresses(self)
list[_T] match(self, BluetoothServiceInfoBleak service_info)
web.Response get(self, web.Request request, str config_key)
None install_multiple_zeroconf_catcher(HaZeroconf hass_zc)
bool _async_zc_has_functional_dual_stack()
bool _memorized_fnmatch(str name, str pattern)
re.Pattern _compile_fnmatch(str pattern)
str _truncate_location_name_to_valid(str location_name)
None _suppress_invalid_properties(dict properties)
None _async_register_hass_zc_service(HomeAssistant hass, HaAsyncZeroconf aio_zc, str uuid)
HaZeroconf async_get_instance(HomeAssistant hass)
HaAsyncZeroconf async_get_async_instance(HomeAssistant hass)
HaAsyncZeroconf _async_get_instance(HomeAssistant hass, **Any zcargs)
str _filter_disallowed_characters(str name)
bool async_setup(HomeAssistant hass, ConfigType config)
bool is_homekit_paired(dict[str, Any] props)
HomeKitDiscoveredIntegration|None async_get_homekit_discovery(dict[str, HomeKitDiscoveredIntegration] homekit_model_lookups, dict[re.Pattern, HomeKitDiscoveredIntegration] homekit_model_matchers, dict[str, Any] props)
ZeroconfServiceInfo|None info_from_service(AsyncServiceInfo service)
tuple[ dict[str, HomeKitDiscoveredIntegration], dict[re.Pattern, HomeKitDiscoveredIntegration],] _build_homekit_model_lookups(dict[str, HomeKitDiscoveredIntegration] homekit_models)
bool _match_against_props(dict[str, str] matcher, dict[str, str|None] props)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
dict[str, list[ZeroconfMatcher]] async_get_zeroconf(HomeAssistant hass)
dict[str, HomeKitDiscoveredIntegration] async_get_homekit(HomeAssistant hass)
None async_when_setup_or_start(core.HomeAssistant hass, str component, Callable[[core.HomeAssistant, str], Awaitable[None]] when_setup_cb)