If a device on the network presented a bad name, zeroconf would throw zeroconf.BadTypeInNameException and the service browser would die off. We now trap the exception and continue.
361 lines
10 KiB
Python
361 lines
10 KiB
Python
"""Support for exposing Home Assistant via Zeroconf."""
|
|
import asyncio
|
|
import ipaddress
|
|
import logging
|
|
import socket
|
|
|
|
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 (
|
|
ATTR_NAME,
|
|
EVENT_HOMEASSISTANT_START,
|
|
EVENT_HOMEASSISTANT_STARTED,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
__version__,
|
|
)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
|
from homeassistant.helpers.singleton import singleton
|
|
from homeassistant.loader import async_get_homekit, async_get_zeroconf
|
|
|
|
from .usage import install_multiple_zeroconf_catcher
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
DOMAIN = "zeroconf"
|
|
|
|
ATTR_HOST = "host"
|
|
ATTR_PORT = "port"
|
|
ATTR_HOSTNAME = "hostname"
|
|
ATTR_TYPE = "type"
|
|
ATTR_PROPERTIES = "properties"
|
|
|
|
ZEROCONF_TYPE = "_home-assistant._tcp.local."
|
|
HOMEKIT_TYPE = "_hap._tcp.local."
|
|
|
|
CONF_DEFAULT_INTERFACE = "default_interface"
|
|
CONF_IPV6 = "ipv6"
|
|
DEFAULT_DEFAULT_INTERFACE = False
|
|
DEFAULT_IPV6 = True
|
|
|
|
HOMEKIT_PROPERTIES = "properties"
|
|
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
|
|
HOMEKIT_MODEL = "md"
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@singleton(DOMAIN)
|
|
async def async_get_instance(hass):
|
|
"""Zeroconf instance to be shared with other integrations that use it."""
|
|
return await hass.async_add_executor_job(_get_instance, hass)
|
|
|
|
|
|
def _get_instance(hass, default_interface=False, ipv6=True):
|
|
"""Create an instance."""
|
|
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
|
|
|
|
zc_args = {}
|
|
if default_interface:
|
|
zc_args["interfaces"] = InterfaceChoice.Default
|
|
if not ipv6:
|
|
zc_args["ip_version"] = IPVersion.V4Only
|
|
|
|
zeroconf = HaZeroconf(**zc_args)
|
|
|
|
def stop_zeroconf(_):
|
|
"""Stop Zeroconf."""
|
|
zeroconf.ha_close()
|
|
|
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
|
|
|
|
return zeroconf
|
|
|
|
|
|
class HaServiceBrowser(ServiceBrowser):
|
|
"""ServiceBrowser that only consumes DNSPointer records."""
|
|
|
|
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
|
|
|
|
|
|
def setup(hass, config):
|
|
"""Set up Zeroconf and make Home Assistant discoverable."""
|
|
zc_config = config.get(DOMAIN, {})
|
|
zeroconf = hass.data[DOMAIN] = _get_instance(
|
|
hass,
|
|
default_interface=zc_config.get(
|
|
CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE
|
|
),
|
|
ipv6=zc_config.get(CONF_IPV6, DEFAULT_IPV6),
|
|
)
|
|
|
|
install_multiple_zeroconf_catcher(zeroconf)
|
|
|
|
# Get instance UUID
|
|
uuid = asyncio.run_coroutine_threadsafe(
|
|
hass.helpers.instance_id.async_get(), hass.loop
|
|
).result()
|
|
|
|
params = {
|
|
"location_name": hass.config.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
|
|
try:
|
|
params["external_url"] = get_url(hass, allow_internal=False)
|
|
except NoURLAvailableError:
|
|
pass
|
|
|
|
try:
|
|
params["internal_url"] = get_url(hass, allow_external=False)
|
|
except NoURLAvailableError:
|
|
pass
|
|
|
|
# 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)
|
|
|
|
info = ServiceInfo(
|
|
ZEROCONF_TYPE,
|
|
name=f"{hass.config.location_name}.{ZEROCONF_TYPE}",
|
|
server=f"{uuid}.local.",
|
|
addresses=[host_ip_pton],
|
|
port=hass.http.server_port,
|
|
properties=params,
|
|
)
|
|
|
|
def zeroconf_hass_start(_event):
|
|
"""Expose Home Assistant on zeroconf when it starts.
|
|
|
|
Wait till started or otherwise HTTP is not up and running.
|
|
"""
|
|
_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"
|
|
)
|
|
|
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start)
|
|
|
|
zeroconf_types = {}
|
|
homekit_models = {}
|
|
|
|
def service_update(zeroconf, service_type, name, state_change):
|
|
"""Service state changed."""
|
|
nonlocal zeroconf_types
|
|
nonlocal homekit_models
|
|
|
|
if state_change != ServiceStateChange.Added:
|
|
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.
|
|
if service_type == HOMEKIT_TYPE:
|
|
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
|
|
|
|
for domain in zeroconf_types[service_type]:
|
|
hass.add_job(
|
|
hass.config_entries.flow.async_init(
|
|
domain, context={"source": DOMAIN}, data=info
|
|
)
|
|
)
|
|
|
|
async def zeroconf_hass_started(_event):
|
|
"""Start the service browser."""
|
|
nonlocal zeroconf_types
|
|
nonlocal homekit_models
|
|
|
|
zeroconf_types = await async_get_zeroconf(hass)
|
|
homekit_models = await async_get_homekit(hass)
|
|
|
|
types = list(zeroconf_types)
|
|
|
|
if HOMEKIT_TYPE not in zeroconf_types:
|
|
types.append(HOMEKIT_TYPE)
|
|
|
|
_LOGGER.debug("Starting Zeroconf browser")
|
|
HaServiceBrowser(zeroconf, types, handlers=[service_update])
|
|
|
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_STARTED, zeroconf_hass_started)
|
|
|
|
return True
|
|
|
|
|
|
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
|
|
|
|
try:
|
|
if isinstance(value, bytes):
|
|
properties[key] = value.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
pass
|
|
|
|
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
|