mirror of
https://github.com/home-assistant/core.git
synced 2025-04-28 19:27:51 +00:00

* 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
316 lines
10 KiB
Python
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())
|