core/homeassistant/components/zeroconf/__init__.py

429 lines
13 KiB
Python
Raw Normal View History

"""Support for exposing Home Assistant via Zeroconf."""
2021-03-18 21:58:19 +00:00
from __future__ import annotations
from contextlib import suppress
import fnmatch
from functools import partial
import ipaddress
2016-04-10 22:34:04 +00:00
import logging
import socket
2016-04-10 22:34:04 +00:00
import voluptuous as vol
from zeroconf import (
DNSPointer,
DNSRecord,
Error as ZeroconfError,
InterfaceChoice,
IPVersion,
NonUniqueNameException,
ServiceBrowser,
ServiceInfo,
ServiceStateChange,
Zeroconf,
)
from homeassistant import util
from homeassistant.const import (
2019-12-22 11:01:22 +00:00
ATTR_NAME,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
__version__,
)
import homeassistant.helpers.config_validation as cv
2020-05-08 19:53:28 +00:00
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton
from homeassistant.loader import async_get_homekit, async_get_zeroconf
2016-04-10 22:34:04 +00:00
from .usage import install_multiple_zeroconf_catcher
_LOGGER = logging.getLogger(__name__)
2016-04-10 22:34:04 +00:00
2019-07-31 19:25:30 +00:00
DOMAIN = "zeroconf"
2019-07-31 19:25:30 +00:00
ATTR_HOST = "host"
ATTR_PORT = "port"
ATTR_HOSTNAME = "hostname"
ATTR_TYPE = "type"
ATTR_PROPERTIES = "properties"
2016-04-10 22:34:04 +00:00
2019-07-31 19:25:30 +00:00
ZEROCONF_TYPE = "_home-assistant._tcp.local."
2021-01-07 18:44:34 +00:00
HOMEKIT_TYPES = [
"_hap._tcp.local.",
# Thread based devices
"_hap._udp.local.",
]
2016-04-10 22:34:04 +00:00
CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = True
DEFAULT_IPV6 = True
HOMEKIT_PROPERTIES = "properties"
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md"
# 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
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
): cv.boolean,
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
2016-04-10 22:34:04 +00:00
@singleton(DOMAIN)
async def async_get_instance(hass):
"""Zeroconf instance to be shared with other integrations that use it."""
return await _async_get_instance(hass)
async def _async_get_instance(hass, **zcargs):
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
zeroconf = await hass.async_add_executor_job(partial(HaZeroconf, **zcargs))
install_multiple_zeroconf_catcher(zeroconf)
def _stop_zeroconf(_):
"""Stop Zeroconf."""
zeroconf.ha_close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_zeroconf)
return zeroconf
class HaServiceBrowser(ServiceBrowser):
"""ServiceBrowser that only consumes DNSPointer records."""
2021-03-18 21:58:19 +00:00
def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None:
"""Pre-Filter update_record to DNSPointers for the configured type."""
#
# Each ServerBrowser currently runs in its own thread which
# processes every A or AAAA record update per instance.
#
# As the list of zeroconf names we watch for grows, each additional
# ServiceBrowser would process all the A and AAAA updates on the network.
#
# To avoid overwhemling the system we pre-filter here and only process
# DNSPointers for the configured record name (type)
#
if record.name not in self.types or not isinstance(record, DNSPointer):
return
super().update_record(zc, now, record)
class HaZeroconf(Zeroconf):
"""Zeroconf that cannot be closed."""
def close(self):
"""Fake method to avoid integrations closing it."""
ha_close = Zeroconf.close
async def async_setup(hass, config):
"""Set up Zeroconf and make Home Assistant discoverable."""
zc_config = config.get(DOMAIN, {})
zc_args = {}
if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE):
zc_args["interfaces"] = InterfaceChoice.Default
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
zc_args["ip_version"] = IPVersion.V4Only
zeroconf = hass.data[DOMAIN] = await _async_get_instance(hass, **zc_args)
async def _async_zeroconf_hass_start(_event):
"""Expose Home Assistant on zeroconf when it starts.
Wait till started or otherwise HTTP is not up and running.
"""
uuid = await hass.helpers.instance_id.async_get()
await hass.async_add_executor_job(
_register_hass_zc_service, hass, zeroconf, uuid
)
async def _async_zeroconf_hass_started(_event):
"""Start the service browser."""
await _async_start_zeroconf_browser(hass, zeroconf)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_zeroconf_hass_start)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _async_zeroconf_hass_started
)
return True
2016-04-10 22:34:04 +00:00
def _register_hass_zc_service(hass, zeroconf, uuid):
# Get instance UUID
valid_location_name = _truncate_location_name_to_valid(hass.config.location_name)
params = {
"location_name": valid_location_name,
"uuid": uuid,
2019-07-31 19:25:30 +00:00
"version": __version__,
"external_url": "",
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
2019-07-31 19:25:30 +00:00
"requires_api_password": True,
}
2016-04-10 22:34:04 +00:00
# Get instance URL's
with suppress(NoURLAvailableError):
2020-05-08 19:53:28 +00:00
params["external_url"] = get_url(hass, allow_internal=False)
with suppress(NoURLAvailableError):
2020-05-08 19:53:28 +00:00
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"]
host_ip = util.get_local_ip()
try:
host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip)
except OSError:
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)
_suppress_invalid_properties(params)
2019-07-31 19:25:30 +00:00
info = ServiceInfo(
ZEROCONF_TYPE,
name=f"{valid_location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
2019-07-31 19:25:30 +00:00
addresses=[host_ip_pton],
port=hass.http.server_port,
properties=params,
)
_LOGGER.info("Starting Zeroconf broadcast")
try:
zeroconf.register_service(info)
except NonUniqueNameException:
_LOGGER.error(
"Home Assistant instance with identical name present in the local network"
)
async def _async_start_zeroconf_browser(hass, zeroconf):
"""Start the zeroconf browser."""
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
types = list(zeroconf_types)
2016-04-10 22:34:04 +00:00
2021-01-07 18:44:34 +00:00
for hk_type in HOMEKIT_TYPES:
if hk_type not in zeroconf_types:
types.append(hk_type)
def service_update(zeroconf, service_type, name, state_change):
"""Service state changed."""
nonlocal zeroconf_types
nonlocal homekit_models
if state_change == ServiceStateChange.Removed:
return
try:
service_info = zeroconf.get_service_info(service_type, name)
except ZeroconfError:
_LOGGER.exception("Failed to get info for device %s", name)
return
if not service_info:
# Prevent the browser thread from collapsing as
# service_info can be None
_LOGGER.debug("Failed to get info for device %s", name)
return
info = info_from_service(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)
# If we can handle it as a HomeKit discovery, we do that here.
2021-01-07 18:44:34 +00:00
if service_type in HOMEKIT_TYPES:
discovery_was_forwarded = handle_homekit(hass, homekit_models, info)
# 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 (
discovery_was_forwarded
and HOMEKIT_PROPERTIES in info
and HOMEKIT_PAIRED_STATUS_FLAG in info[HOMEKIT_PROPERTIES]
):
try:
# 0 means paired and not discoverable by iOS clients)
if int(info[HOMEKIT_PROPERTIES][HOMEKIT_PAIRED_STATUS_FLAG]):
return
except ValueError:
# HomeKit pairing status unknown
# likely bad homekit data
return
2020-12-31 00:06:26 +00:00
if "name" in info:
lowercase_name = info["name"].lower()
else:
lowercase_name = None
if "macaddress" in info.get("properties", {}):
uppercase_mac = info["properties"]["macaddress"].upper()
else:
uppercase_mac = None
2021-01-07 18:44:34 +00:00
# Not all homekit types are currently used for discovery
# so not all service type exist in zeroconf_types
for entry in zeroconf_types.get(service_type, []):
if len(entry) > 1:
2020-12-31 00:06:26 +00:00
if (
uppercase_mac is not None
and "macaddress" in entry
and not fnmatch.fnmatch(uppercase_mac, entry["macaddress"])
):
continue
if (
lowercase_name is not None
and "name" in entry
and not fnmatch.fnmatch(lowercase_name, entry["name"])
):
continue
hass.add_job(
hass.config_entries.flow.async_init(
entry["domain"], context={"source": DOMAIN}, data=info
)
)
_LOGGER.debug("Starting Zeroconf browser")
HaServiceBrowser(zeroconf, types, handlers=[service_update])
def handle_homekit(hass, homekit_models, info) -> bool:
"""Handle a HomeKit discovery.
Return if discovery was forwarded.
"""
model = None
props = info.get(HOMEKIT_PROPERTIES, {})
for key in props:
if key.lower() == HOMEKIT_MODEL:
model = props[key]
break
if model is None:
return False
for test_model in homekit_models:
if (
model != test_model
and not model.startswith(f"{test_model} ")
and not model.startswith(f"{test_model}-")
):
continue
hass.add_job(
hass.config_entries.flow.async_init(
homekit_models[test_model], context={"source": "homekit"}, data=info
)
)
return True
return False
def info_from_service(service):
"""Return prepared info from mDNS entries."""
properties = {"_raw": {}}
for key, value in service.properties.items():
# 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
try:
key = key.decode("ascii")
except UnicodeDecodeError:
_LOGGER.debug(
"Ignoring invalid key provided by [%s]: %s", service.name, key
)
continue
properties["_raw"][key] = value
with suppress(UnicodeDecodeError):
if isinstance(value, bytes):
properties[key] = value.decode("utf-8")
if not service.addresses:
return None
address = service.addresses[0]
info = {
ATTR_HOST: str(ipaddress.ip_address(address)),
ATTR_PORT: service.port,
ATTR_HOSTNAME: service.server,
ATTR_TYPE: service.type,
ATTR_NAME: service.name,
ATTR_PROPERTIES: properties,
}
return info
def _suppress_invalid_properties(properties):
"""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):
"""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")