-
-
Notifications
You must be signed in to change notification settings - Fork 30.1k
/
__init__.py
629 lines (518 loc) · 21 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
"""Support for exposing Home Assistant via Zeroconf."""
from __future__ import annotations
import asyncio
import contextlib
from contextlib import suppress
from dataclasses import dataclass
from fnmatch import translate
from functools import lru_cache
from ipaddress import IPv4Address, IPv6Address
import logging
import re
import sys
from typing import Any, Final, cast
import voluptuous as vol
from zeroconf import (
BadTypeInNameException,
InterfaceChoice,
IPVersion,
ServiceStateChange,
)
from zeroconf.asyncio import AsyncServiceInfo
from homeassistant import config_entries
from homeassistant.components import network
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.data_entry_flow import BaseServiceInfo
from homeassistant.helpers import discovery_flow, instance_id
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import (
HomeKitDiscoveredIntegration,
async_get_homekit,
async_get_zeroconf,
bind_hass,
)
from homeassistant.setup import async_when_setup_or_start
from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf
from .usage import install_multiple_zeroconf_catcher
_LOGGER = logging.getLogger(__name__)
DOMAIN = "zeroconf"
ZEROCONF_TYPE = "_home-assistant._tcp.local."
HOMEKIT_TYPES = [
"_hap._tcp.local.",
# Thread based devices
"_hap._udp.local.",
]
_HOMEKIT_MODEL_SPLITS = (None, " ", "-")
# Top level keys we support matching against in properties that are always matched in
# lower case. ex: ZeroconfServiceInfo.name
LOWER_MATCH_ATTRS = {"name"}
CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = True
DEFAULT_IPV6 = True
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL_LOWER = "md"
HOMEKIT_MODEL_UPPER = "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
ATTR_PROPERTIES: Final = "properties"
# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES]
ATTR_PROPERTIES_ID: Final = "id"
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,
)
@dataclass(slots=True)
class ZeroconfServiceInfo(BaseServiceInfo):
"""Prepared info from mDNS entries."""
host: str
addresses: list[str]
port: int | None
hostname: str
type: str
name: str
properties: dict[str, Any]
@bind_hass
async def async_get_instance(hass: HomeAssistant) -> HaZeroconf:
"""Zeroconf instance to be shared with other integrations that use it."""
return cast(HaZeroconf, (await _async_get_instance(hass)).zeroconf)
@bind_hass
async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
"""Zeroconf instance to be shared with other integrations that use it."""
return await _async_get_instance(hass)
async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
zeroconf = HaZeroconf(**zcargs)
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()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _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"
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable."""
zc_args: dict = {"ip_version": IPVersion.V4Only}
adapters = await network.async_get_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 await network.async_get_enabled_source_ips(hass)
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
)
]
aio_zc = await _async_get_instance(hass, **zc_args)
zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
zeroconf_types, homekit_models = await asyncio.gather(
async_get_zeroconf(hass), 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,
ipv6,
)
await discovery.async_setup()
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 _build_homekit_model_lookups(
homekit_models: dict[str, HomeKitDiscoveredIntegration]
) -> tuple[
dict[str, HomeKitDiscoveredIntegration],
dict[re.Pattern, HomeKitDiscoveredIntegration],
]:
"""Build lookups for homekit models."""
homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {}
homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {}
for model, discovery in homekit_models.items():
if "*" in model or "?" in model or "[" in model:
homekit_model_matchers[_compile_fnmatch(model)] = discovery
else:
homekit_model_lookup[model] = discovery
return homekit_model_lookup, homekit_model_matchers
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 _match_against_data(
matcher: dict[str, str | dict[str, str]], match_data: dict[str, str]
) -> bool:
"""Check a matcher to ensure all values in match_data match."""
for key in LOWER_MATCH_ATTRS:
if key not in matcher:
continue
if key not in match_data:
return False
match_val = matcher[key]
assert isinstance(match_val, str)
if not _memorized_fnmatch(match_data[key], match_val):
return False
return True
def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool:
"""Check a matcher to ensure all values in props."""
return not any(
key
for key in matcher
if key not in props or not _memorized_fnmatch(props[key].lower(), matcher[key])
)
def is_homekit_paired(props: dict[str, Any]) -> bool:
"""Check properties to see if a device is homekit paired."""
if HOMEKIT_PAIRED_STATUS_FLAG not in props:
return False
with contextlib.suppress(ValueError):
# 0 means paired and not discoverable by iOS clients)
return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0
# If we cannot tell, we assume its not paired
return False
class ZeroconfDiscovery:
"""Discovery via zeroconf."""
def __init__(
self,
hass: HomeAssistant,
zeroconf: HaZeroconf,
zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]],
homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
ipv6: bool,
) -> None:
"""Init discovery."""
self.hass = hass
self.zeroconf = zeroconf
self.zeroconf_types = zeroconf_types
self.homekit_model_lookups = homekit_model_lookups
self.homekit_model_matchers = homekit_model_matchers
self.ipv6 = ipv6
self.async_service_browser: HaAsyncServiceBrowser | None = None
async def async_setup(self) -> None:
"""Start discovery."""
types = list(self.zeroconf_types)
# We want to make sure we know about other HomeAssistant
# instances as soon as possible to avoid name conflicts
# so we always browse for ZEROCONF_TYPE
for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES):
if hk_type not in self.zeroconf_types:
types.append(hk_type)
_LOGGER.debug("Starting Zeroconf browser for: %s", types)
self.async_service_browser = HaAsyncServiceBrowser(
self.ipv6, self.zeroconf, types, handlers=[self.async_service_update]
)
async def async_stop(self) -> None:
"""Cancel the service browser and stop processing the queue."""
if self.async_service_browser:
await self.async_service_browser.async_cancel()
def _async_dismiss_discoveries(self, name: str) -> None:
"""Dismiss all discoveries for the given name."""
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
ZeroconfServiceInfo,
lambda service_info: bool(service_info.name == name),
):
self.hass.config_entries.flow.async_abort(flow["flow_id"])
@callback
def async_service_update(
self,
zeroconf: HaZeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
"""Service state changed."""
_LOGGER.debug(
"service_update: type=%s name=%s state_change=%s",
service_type,
name,
state_change,
)
if state_change == ServiceStateChange.Removed:
self._async_dismiss_discoveries(name)
return
try:
async_service_info = AsyncServiceInfo(service_type, name)
except BadTypeInNameException as ex:
# Some devices broadcast a name that is not a valid DNS name
# This is a bug in the device firmware and we should ignore it
_LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex)
return
if async_service_info.load_from_cache(zeroconf):
self._async_process_service_update(async_service_info, service_type, name)
else:
self.hass.async_create_task(
self._async_lookup_and_process_service_update(
zeroconf, async_service_info, service_type, name
)
)
async def _async_lookup_and_process_service_update(
self,
zeroconf: HaZeroconf,
async_service_info: AsyncServiceInfo,
service_type: str,
name: str,
) -> None:
"""Update and process a zeroconf update."""
await async_service_info.async_request(zeroconf, 3000)
self._async_process_service_update(async_service_info, service_type, name)
@callback
def _async_process_service_update(
self, async_service_info: AsyncServiceInfo, service_type: str, name: str
) -> None:
"""Process a zeroconf update."""
info = info_from_service(async_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)
props: dict[str, str] = info.properties
domain = None
# If we can handle it as a HomeKit discovery, we do that here.
if service_type in HOMEKIT_TYPES and (
homekit_discovery := async_get_homekit_discovery(
self.homekit_model_lookups, self.homekit_model_matchers, props
)
):
domain = homekit_discovery.domain
discovery_flow.async_create_flow(
self.hass,
homekit_discovery.domain,
{"source": config_entries.SOURCE_HOMEKIT},
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 not is_homekit_paired(props) and not homekit_discovery.always_discover:
# If the device is paired with HomeKit we must send on
# the update to homekit_controller so it can see when
# the 'c#' field is updated. This is used to detect
# when the device has been reset or updated.
#
# If the device is not paired and we should not always
# discover it, we can stop here.
return
match_data: dict[str, str] = {}
for key in LOWER_MATCH_ATTRS:
attr_value: str = getattr(info, key)
match_data[key] = attr_value.lower()
# Not all homekit types are currently used for discovery
# so not all service type exist in zeroconf_types
for matcher in self.zeroconf_types.get(service_type, []):
if len(matcher) > 1:
if not _match_against_data(matcher, match_data):
continue
if ATTR_PROPERTIES in matcher:
matcher_props = matcher[ATTR_PROPERTIES]
assert isinstance(matcher_props, dict)
if not _match_against_props(matcher_props, props):
continue
matcher_domain = matcher["domain"]
assert isinstance(matcher_domain, str)
context = {
"source": config_entries.SOURCE_ZEROCONF,
}
if domain:
# Domain of integration that offers alternative API to handle
# this device.
context["alternative_domain"] = domain
discovery_flow.async_create_flow(
self.hass,
matcher_domain,
context,
info,
)
def async_get_homekit_discovery(
homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
props: dict[str, Any],
) -> HomeKitDiscoveredIntegration | None:
"""Handle a HomeKit discovery.
Return the domain to forward the discovery data to
"""
if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)):
return None
assert isinstance(model, str)
for split_str in _HOMEKIT_MODEL_SPLITS:
key = (model.split(split_str))[0] if split_str else model
if discovery := homekit_model_lookups.get(key):
return discovery
for pattern, discovery in homekit_model_matchers.items():
if pattern.match(model):
return discovery
return None
@lru_cache(maxsize=256) # matches to the cache in zeroconf itself
def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str:
"""Stringify an IP address."""
return str(ip_addr)
def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
"""Return prepared info from mDNS entries."""
# 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
if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)):
return None
host: str | None = None
for ip_addr in ip_addresses:
if not ip_addr.is_link_local and not ip_addr.is_unspecified:
host = _stringify_ip_address(ip_addr)
break
if not host:
return None
properties: dict[str, Any] = {
k.decode("ascii", "replace"): None
if v is None
else v.decode("utf-8", "replace")
for k, v in service.properties.items()
}
assert service.server is not None, "server cannot be none if there are addresses"
return ZeroconfServiceInfo(
host=host,
addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses],
port=service.port,
hostname=service.server,
type=service.type,
name=service.name,
properties=properties,
)
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")
@lru_cache(maxsize=4096, typed=True)
def _compile_fnmatch(pattern: str) -> re.Pattern:
"""Compile a fnmatch pattern."""
return re.compile(translate(pattern))
@lru_cache(maxsize=1024, typed=True)
def _memorized_fnmatch(name: str, pattern: str) -> bool:
"""Memorized version of fnmatch that has a larger lru_cache.
The default version of fnmatch only has a lru_cache of 256 entries.
With many devices we quickly reach that limit and end up compiling
the same pattern over and over again.
Zeroconf has its own memorized fnmatch with its own lru_cache
since the data is going to be relatively the same
since the devices will not change frequently
"""
return bool(_compile_fnmatch(pattern).match(name))