2019-02-13 20:21:14 +00:00
|
|
|
"""Support for exposing Home Assistant via Zeroconf."""
|
2019-12-09 17:54:54 +00:00
|
|
|
import ipaddress
|
2016-04-10 22:34:04 +00:00
|
|
|
import logging
|
2019-06-05 15:13:40 +00:00
|
|
|
import socket
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2016-09-30 02:02:22 +00:00
|
|
|
import voluptuous as vol
|
2019-11-23 15:46:06 +00:00
|
|
|
from zeroconf import (
|
2020-05-06 14:16:59 +00:00
|
|
|
InterfaceChoice,
|
2019-12-09 17:54:54 +00:00
|
|
|
NonUniqueNameException,
|
2019-11-23 15:46:06 +00:00
|
|
|
ServiceBrowser,
|
|
|
|
ServiceInfo,
|
|
|
|
ServiceStateChange,
|
|
|
|
Zeroconf,
|
|
|
|
)
|
2019-05-21 22:36:26 +00:00
|
|
|
|
2019-06-05 15:13:40 +00:00
|
|
|
from homeassistant import util
|
2019-10-13 21:16:27 +00:00
|
|
|
from homeassistant.const import (
|
2019-12-22 11:01:22 +00:00
|
|
|
ATTR_NAME,
|
2019-10-13 21:16:27 +00:00
|
|
|
EVENT_HOMEASSISTANT_START,
|
2019-12-09 17:54:54 +00:00
|
|
|
EVENT_HOMEASSISTANT_STOP,
|
2019-10-13 21:16:27 +00:00
|
|
|
__version__,
|
|
|
|
)
|
2019-12-09 17:54:54 +00:00
|
|
|
from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF
|
2020-05-06 14:16:59 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2020-05-08 19:53:28 +00:00
|
|
|
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2016-09-30 02:02:22 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN = "zeroconf"
|
2016-04-10 23:09:52 +00:00
|
|
|
|
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."
|
|
|
|
HOMEKIT_TYPE = "_hap._tcp.local."
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2020-05-06 14:16:59 +00:00
|
|
|
CONF_DEFAULT_INTERFACE = "default_interface"
|
|
|
|
DEFAULT_DEFAULT_INTERFACE = False
|
|
|
|
|
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
DOMAIN: vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Optional(
|
|
|
|
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
|
|
|
|
): cv.boolean
|
|
|
|
}
|
|
|
|
)
|
|
|
|
},
|
|
|
|
extra=vol.ALLOW_EXTRA,
|
|
|
|
)
|
2016-04-10 22:34:04 +00:00
|
|
|
|
|
|
|
|
2019-05-29 21:20:06 +00:00
|
|
|
def setup(hass, config):
|
2016-04-10 23:02:07 +00:00
|
|
|
"""Set up Zeroconf and make Home Assistant discoverable."""
|
2020-05-10 19:30:54 +00:00
|
|
|
if DOMAIN in config and config[DOMAIN].get(CONF_DEFAULT_INTERFACE):
|
2020-05-06 14:16:59 +00:00
|
|
|
zeroconf = Zeroconf(interfaces=InterfaceChoice.Default)
|
|
|
|
else:
|
|
|
|
zeroconf = Zeroconf()
|
2019-08-23 16:53:33 +00:00
|
|
|
zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}"
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2016-09-30 02:02:22 +00:00
|
|
|
params = {
|
2019-07-31 19:25:30 +00:00
|
|
|
"version": __version__,
|
2020-05-08 00:29:47 +00:00
|
|
|
"external_url": None,
|
|
|
|
"internal_url": None,
|
|
|
|
# Old base URL, for backward compatibility
|
|
|
|
"base_url": None,
|
2019-11-23 15:46:06 +00:00
|
|
|
# Always needs authentication
|
2019-07-31 19:25:30 +00:00
|
|
|
"requires_api_password": True,
|
2016-09-30 02:02:22 +00:00
|
|
|
}
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2020-05-08 00:29:47 +00:00
|
|
|
try:
|
2020-05-08 19:53:28 +00:00
|
|
|
params["external_url"] = get_url(hass, allow_internal=False)
|
2020-05-08 00:29:47 +00:00
|
|
|
except NoURLAvailableError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
try:
|
2020-05-08 19:53:28 +00:00
|
|
|
params["internal_url"] = get_url(hass, allow_external=False)
|
2020-05-08 00:29:47 +00:00
|
|
|
except NoURLAvailableError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Set old base URL based on external or internal
|
|
|
|
params["base_url"] = params["external_url"] or params["internal_url"]
|
|
|
|
|
2019-06-05 15:13:40 +00:00
|
|
|
host_ip = util.get_local_ip()
|
|
|
|
|
|
|
|
try:
|
|
|
|
host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip)
|
2020-04-04 20:09:11 +00:00
|
|
|
except OSError:
|
2019-06-05 15:13:40 +00:00
|
|
|
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
info = ServiceInfo(
|
|
|
|
ZEROCONF_TYPE,
|
|
|
|
zeroconf_name,
|
|
|
|
None,
|
|
|
|
addresses=[host_ip_pton],
|
|
|
|
port=hass.http.server_port,
|
|
|
|
properties=params,
|
|
|
|
)
|
2019-05-16 19:04:20 +00:00
|
|
|
|
2019-10-13 21:16:27 +00:00
|
|
|
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")
|
2019-11-23 15:46:06 +00:00
|
|
|
try:
|
|
|
|
zeroconf.register_service(info)
|
|
|
|
except NonUniqueNameException:
|
|
|
|
_LOGGER.error(
|
|
|
|
"Home Assistant instance with identical name present in the local network"
|
|
|
|
)
|
2017-01-03 20:39:42 +00:00
|
|
|
|
2019-10-13 21:16:27 +00:00
|
|
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start)
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2019-05-29 21:20:06 +00:00
|
|
|
def service_update(zeroconf, service_type, name, state_change):
|
2019-05-21 22:36:26 +00:00
|
|
|
"""Service state changed."""
|
2019-05-31 18:58:48 +00:00
|
|
|
if state_change != ServiceStateChange.Added:
|
|
|
|
return
|
|
|
|
|
|
|
|
service_info = zeroconf.get_service_info(service_type, name)
|
|
|
|
info = info_from_service(service_info)
|
|
|
|
_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 and handle_homekit(hass, info):
|
|
|
|
return
|
|
|
|
|
|
|
|
for domain in ZEROCONF[service_type]:
|
|
|
|
hass.add_job(
|
|
|
|
hass.config_entries.flow.async_init(
|
2019-07-31 19:25:30 +00:00
|
|
|
domain, context={"source": DOMAIN}, data=info
|
2019-05-29 21:20:06 +00:00
|
|
|
)
|
2019-05-31 18:58:48 +00:00
|
|
|
)
|
2019-05-29 21:20:06 +00:00
|
|
|
|
|
|
|
for service in ZEROCONF:
|
2019-05-21 22:36:26 +00:00
|
|
|
ServiceBrowser(zeroconf, service, handlers=[service_update])
|
|
|
|
|
2019-05-31 18:58:48 +00:00
|
|
|
if HOMEKIT_TYPE not in ZEROCONF:
|
|
|
|
ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update])
|
|
|
|
|
2019-05-29 21:20:06 +00:00
|
|
|
def stop_zeroconf(_):
|
2016-04-10 23:02:07 +00:00
|
|
|
"""Stop Zeroconf."""
|
2019-05-29 21:20:06 +00:00
|
|
|
zeroconf.close()
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2019-05-29 21:20:06 +00:00
|
|
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
|
2016-04-10 22:34:04 +00:00
|
|
|
|
2016-04-10 23:02:07 +00:00
|
|
|
return True
|
2019-05-21 22:36:26 +00:00
|
|
|
|
|
|
|
|
2019-05-31 18:58:48 +00:00
|
|
|
def handle_homekit(hass, info) -> bool:
|
|
|
|
"""Handle a HomeKit discovery.
|
|
|
|
|
|
|
|
Return if discovery was forwarded.
|
|
|
|
"""
|
|
|
|
model = None
|
2019-07-31 19:25:30 +00:00
|
|
|
props = info.get("properties", {})
|
2019-05-31 18:58:48 +00:00
|
|
|
|
|
|
|
for key in props:
|
2019-07-31 19:25:30 +00:00
|
|
|
if key.lower() == "md":
|
2019-05-31 18:58:48 +00:00
|
|
|
model = props[key]
|
|
|
|
break
|
|
|
|
|
|
|
|
if model is None:
|
|
|
|
return False
|
|
|
|
|
|
|
|
for test_model in HOMEKIT:
|
2020-03-26 22:15:35 +00:00
|
|
|
if (
|
|
|
|
model != test_model
|
2020-04-05 14:01:41 +00:00
|
|
|
and not model.startswith(f"{test_model} ")
|
|
|
|
and not model.startswith(f"{test_model}-")
|
2020-03-26 22:15:35 +00:00
|
|
|
):
|
2019-05-31 18:58:48 +00:00
|
|
|
continue
|
|
|
|
|
|
|
|
hass.add_job(
|
|
|
|
hass.config_entries.flow.async_init(
|
2019-07-31 19:25:30 +00:00
|
|
|
HOMEKIT[test_model], context={"source": "homekit"}, data=info
|
2019-05-31 18:58:48 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
2019-05-21 22:36:26 +00:00
|
|
|
def info_from_service(service):
|
|
|
|
"""Return prepared info from mDNS entries."""
|
2020-01-22 05:24:59 +00:00
|
|
|
properties = {"_raw": {}}
|
2019-05-21 22:36:26 +00:00
|
|
|
|
|
|
|
for key, value in service.properties.items():
|
2020-01-22 05:24:59 +00:00
|
|
|
# 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
|
2020-04-18 00:36:46 +00:00
|
|
|
try:
|
|
|
|
key = key.decode("ascii")
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Ignoring invalid key provided by [%s]: %s", service.name, key
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
|
2020-01-22 05:24:59 +00:00
|
|
|
properties["_raw"][key] = value
|
|
|
|
|
2019-05-21 22:36:26 +00:00
|
|
|
try:
|
|
|
|
if isinstance(value, bytes):
|
2020-01-22 05:24:59 +00:00
|
|
|
properties[key] = value.decode("utf-8")
|
2019-05-21 22:36:26 +00:00
|
|
|
except UnicodeDecodeError:
|
2020-01-22 05:24:59 +00:00
|
|
|
pass
|
2019-05-21 22:36:26 +00:00
|
|
|
|
2019-06-04 21:14:51 +00:00
|
|
|
address = service.addresses[0]
|
2019-05-21 22:36:26 +00:00
|
|
|
|
|
|
|
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
|