J. Nick Koston 4e7d396e5b
Add WebSocket API to zeroconf to observe discovery (#143540)
* Add WebSocket API to zeroconf to observe discovery

* Add WebSocket API to zeroconf to observe discovery

* increase timeout

* cover

* cover

* cover

* cover

* cover

* cover

* fix lasting side effects

* cleanup merge

* format
2025-04-25 21:18:09 -04:00

316 lines
10 KiB
Python

"""Support for exposing Home Assistant via Zeroconf."""
from __future__ import annotations
from contextlib import suppress
from functools import partial
from ipaddress import IPv4Address, IPv6Address
import logging
import sys
from typing import Any, cast
import voluptuous as vol
from zeroconf import InterfaceChoice, IPVersion
from zeroconf.asyncio import AsyncServiceInfo
from homeassistant.components import network
from homeassistant.const import (
EVENT_HOMEASSISTANT_CLOSE,
EVENT_HOMEASSISTANT_STOP,
__version__,
)
from homeassistant.core import Event, HomeAssistant, callback
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.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 async_get_homekit, async_get_zeroconf, bind_hass
from homeassistant.setup import async_when_setup_or_start
from . import websocket_api
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__)
CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = True
DEFAULT_IPV6 = True
# Property key=value has a max length of 255
# so we use 230 to leave space for key=
MAX_PROPERTY_VALUE_LEN = 230
# Dns label max length
MAX_NAME_LEN = 63
# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant(
_ATTR_PROPERTIES_ID,
"homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID",
"2026.2",
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
cv.deprecated(CONF_DEFAULT_INTERFACE),
cv.deprecated(CONF_IPV6),
vol.Schema(
{
vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean,
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
}
),
)
},
extra=vol.ALLOW_EXTRA,
)
_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant(
_ZeroconfServiceInfo,
"homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo",
"2026.2",
)
@bind_hass
async def async_get_instance(hass: HomeAssistant) -> HaZeroconf:
"""Get or create the shared HaZeroconf instance."""
return cast(HaZeroconf, (_async_get_instance(hass)).zeroconf)
@bind_hass
async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
"""Get or create the shared HaAsyncZeroconf instance."""
return _async_get_instance(hass)
@callback
def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
"""Get or create the shared HaAsyncZeroconf instance.
This method must be run in the event loop, and is an alternative
to the async_get_async_instance method when a coroutine cannot be used.
"""
return _async_get_instance(hass)
def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
zeroconf = HaZeroconf(**_async_get_zc_args(hass))
aio_zc = HaAsyncZeroconf(zc=zeroconf)
install_multiple_zeroconf_catcher(zeroconf)
async def _async_stop_zeroconf(_event: Event) -> None:
"""Stop Zeroconf."""
await aio_zc.ha_async_close()
# Wait to the close event to shutdown zeroconf to give
# integrations time to send a good bye message
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_zeroconf)
hass.data[DOMAIN] = aio_zc
return aio_zc
@callback
def _async_zc_has_functional_dual_stack() -> bool:
"""Return true for platforms not supporting IP_ADD_MEMBERSHIP on an AF_INET6 socket.
Zeroconf only supports a single listen socket at this time.
"""
return not sys.platform.startswith("freebsd") and not sys.platform.startswith(
"darwin"
)
def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]:
"""Get zeroconf arguments from config."""
zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only}
adapters = network.async_get_loaded_adapters(hass)
ipv6 = False
if _async_zc_has_functional_dual_stack():
if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters):
ipv6 = True
zc_args["ip_version"] = IPVersion.All
elif not any(adapter["enabled"] and adapter["ipv4"] for adapter in adapters):
zc_args["ip_version"] = IPVersion.V6Only
ipv6 = True
if not ipv6 and network.async_only_default_interface_enabled(adapters):
zc_args["interfaces"] = InterfaceChoice.Default
else:
zc_args["interfaces"] = [
str(source_ip)
for source_ip in network.async_get_enabled_source_ips_from_adapters(
adapters
)
if not source_ip.is_loopback
and not (isinstance(source_ip, IPv6Address) and source_ip.is_global)
and not (
isinstance(source_ip, IPv6Address)
and zc_args["ip_version"] == IPVersion.V4Only
)
and not (
isinstance(source_ip, IPv4Address)
and zc_args["ip_version"] == IPVersion.V6Only
)
]
return zc_args
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable."""
aio_zc = _async_get_instance(hass)
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_models
)
discovery = ZeroconfDiscovery(
hass,
zeroconf,
zeroconf_types,
homekit_model_lookup,
homekit_model_matchers,
)
await discovery.async_setup()
hass.data[DATA_DISCOVERY] = discovery
websocket_api.async_setup(hass)
async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None:
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
"""
uuid = await instance_id.async_get(hass)
await _async_register_hass_zc_service(hass, aio_zc, uuid)
async def _async_zeroconf_hass_stop(_event: Event) -> None:
await discovery.async_stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_zeroconf_hass_stop)
async_when_setup_or_start(hass, "frontend", _async_zeroconf_hass_start)
return True
def _filter_disallowed_characters(name: str) -> str:
"""Filter disallowed characters from a string.
. is a reversed character for zeroconf.
"""
return name.replace(".", " ")
async def _async_register_hass_zc_service(
hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str
) -> None:
# Get instance UUID
valid_location_name = _truncate_location_name_to_valid(
_filter_disallowed_characters(hass.config.location_name or "Home")
)
params = {
"location_name": valid_location_name,
"uuid": uuid,
"version": __version__,
"external_url": "",
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
with suppress(NoURLAvailableError):
params["external_url"] = get_url(hass, allow_internal=False)
with suppress(NoURLAvailableError):
params["internal_url"] = get_url(hass, allow_external=False)
# Set old base URL based on external or internal
params["base_url"] = params["external_url"] or params["internal_url"]
_suppress_invalid_properties(params)
info = AsyncServiceInfo(
ZEROCONF_TYPE,
name=f"{valid_location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
parsed_addresses=await network.async_get_announce_addresses(hass),
port=hass.http.server_port,
properties=params,
)
_LOGGER.info("Starting Zeroconf broadcast")
await aio_zc.async_register_service(info, allow_name_change=True)
def _suppress_invalid_properties(properties: dict) -> None:
"""Suppress any properties that will cause zeroconf to fail to startup."""
for prop, prop_value in properties.items():
if not isinstance(prop_value, str):
continue
if len(prop_value.encode("utf-8")) > MAX_PROPERTY_VALUE_LEN:
_LOGGER.error(
(
"The property '%s' was suppressed because it is longer than the"
" maximum length of %d bytes: %s"
),
prop,
MAX_PROPERTY_VALUE_LEN,
prop_value,
)
properties[prop] = ""
def _truncate_location_name_to_valid(location_name: str) -> str:
"""Truncate or return the location name usable for zeroconf."""
if len(location_name.encode("utf-8")) < MAX_NAME_LEN:
return location_name
_LOGGER.warning(
(
"The location name was truncated because it is longer than the maximum"
" length of %d bytes: %s"
),
MAX_NAME_LEN,
location_name,
)
return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore")
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())