-
-
Notifications
You must be signed in to change notification settings - Fork 28.5k
/
__init__.py
972 lines (844 loc) · 34.6 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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
"""Support for Apple HomeKit."""
from __future__ import annotations
import asyncio
from copy import deepcopy
import ipaddress
import logging
import os
from aiohttp import web
from pyhap.const import STANDALONE_AID
import voluptuous as vol
from homeassistant.components import device_automation, network, zeroconf
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OCCUPANCY,
DOMAIN as BINARY_SENSOR_DOMAIN,
)
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.network.const import MDNS_TARGET_IP
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_DEVICES,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound, async_get_integration
from . import ( # noqa: F401
type_cameras,
type_covers,
type_fans,
type_humidifiers,
type_lights,
type_locks,
type_media_players,
type_remotes,
type_security_systems,
type_sensors,
type_switches,
type_thermostats,
)
from .accessories import HomeBridge, HomeDriver, get_accessory
from .aidmanager import AccessoryAidStorage
from .const import (
ATTR_INTEGRATION,
BRIDGE_NAME,
BRIDGE_SERIAL_NUMBER,
CONF_ADVERTISE_IP,
CONF_ENTITY_CONFIG,
CONF_ENTRY_INDEX,
CONF_EXCLUDE_ACCESSORY_MODE,
CONF_FILTER,
CONF_HOMEKIT_MODE,
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONFIG_OPTIONS,
DEFAULT_EXCLUDE_ACCESSORY_MODE,
DEFAULT_HOMEKIT_MODE,
DEFAULT_PORT,
DOMAIN,
HOMEKIT,
HOMEKIT_MODE_ACCESSORY,
HOMEKIT_MODES,
HOMEKIT_PAIRING_QR,
HOMEKIT_PAIRING_QR_SECRET,
MANUFACTURER,
PERSIST_LOCK,
SERVICE_HOMEKIT_RESET_ACCESSORY,
SERVICE_HOMEKIT_START,
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
)
from .type_triggers import DeviceTriggerAccessory
from .util import (
accessory_friendly_name,
async_dismiss_setup_message,
async_port_is_available,
async_show_setup_message,
get_persist_fullpath_for_entry_id,
remove_state_files_for_entry_id,
state_needs_accessory_mode,
validate_entity_config,
)
_LOGGER = logging.getLogger(__name__)
MAX_DEVICES = 150
# #### Driver Status ####
STATUS_READY = 0
STATUS_RUNNING = 1
STATUS_STOPPED = 2
STATUS_WAIT = 3
PORT_CLEANUP_CHECK_INTERVAL_SECS = 1
_HOMEKIT_CONFIG_UPDATE_TIME = (
5 # number of seconds to wait for homekit to see the c# change
)
def _has_all_unique_names_and_ports(bridges):
"""Validate that each homekit bridge configured has a unique name."""
names = [bridge[CONF_NAME] for bridge in bridges]
ports = [bridge[CONF_PORT] for bridge in bridges]
vol.Schema(vol.Unique())(names)
vol.Schema(vol.Unique())(ports)
return bridges
BRIDGE_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_HOMEKIT_MODE, default=DEFAULT_HOMEKIT_MODE): vol.In(
HOMEKIT_MODES
),
vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All(
cv.string, vol.Length(min=3, max=25)
),
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config,
vol.Optional(CONF_DEVICES): cv.ensure_list,
},
extra=vol.ALLOW_EXTRA,
),
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [BRIDGE_SCHEMA], _has_all_unique_names_and_ports)},
extra=vol.ALLOW_EXTRA,
)
RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_ids}
)
UNPAIR_SERVICE_SCHEMA = vol.All(
vol.Schema(cv.ENTITY_SERVICE_FIELDS),
cv.has_at_least_one_key(ATTR_DEVICE_ID),
)
def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]:
"""All active HomeKit instances."""
return [
data[HOMEKIT]
for data in hass.data[DOMAIN].values()
if isinstance(data, dict) and HOMEKIT in data
]
def _async_get_entries_by_name(current_entries):
"""Return a dict of the entries by name."""
# For backwards compat, its possible the first bridge is using the default
# name.
return {entry.data.get(CONF_NAME, BRIDGE_NAME): entry for entry in current_entries}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HomeKit from yaml."""
hass.data.setdefault(DOMAIN, {})[PERSIST_LOCK] = asyncio.Lock()
_async_register_events_and_services(hass)
if DOMAIN not in config:
return True
current_entries = hass.config_entries.async_entries(DOMAIN)
entries_by_name = _async_get_entries_by_name(current_entries)
for index, conf in enumerate(config[DOMAIN]):
if _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf):
continue
conf[CONF_ENTRY_INDEX] = index
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=conf,
)
)
return True
@callback
def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf):
"""Update a config entry with the latest yaml.
Returns True if a matching config entry was found
Returns False if there is no matching config entry
"""
bridge_name = conf[CONF_NAME]
if (
bridge_name in entries_by_name
and entries_by_name[bridge_name].source == SOURCE_IMPORT
):
entry = entries_by_name[bridge_name]
# If they alter the yaml config we import the changes
# since there currently is no practical way to support
# all the options in the UI at this time.
data = conf.copy()
options = {}
for key in CONFIG_OPTIONS:
if key in data:
options[key] = data[key]
del data[key]
hass.config_entries.async_update_entry(entry, data=data, options=options)
return True
return False
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HomeKit from a config entry."""
_async_import_options_from_data_if_missing(hass, entry)
conf = entry.data
options = entry.options
name = conf[CONF_NAME]
port = conf[CONF_PORT]
_LOGGER.debug("Begin setup HomeKit for %s", name)
# ip_address and advertise_ip are yaml only
ip_address = conf.get(
CONF_IP_ADDRESS, await network.async_get_source_ip(hass, MDNS_TARGET_IP)
)
advertise_ip = conf.get(CONF_ADVERTISE_IP)
# exclude_accessory_mode is only used for config flow
# to indicate that the config entry was setup after
# we started creating config entries for entities that
# to run in accessory mode and that we should never include
# these entities on the bridge. For backwards compatibility
# with users who have not migrated yet we do not do exclude
# these entities by default as we cannot migrate automatically
# since it requires a re-pairing.
exclude_accessory_mode = conf.get(
CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE
)
homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE)
entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy()
entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {}))
devices = options.get(CONF_DEVICES, [])
homekit = HomeKit(
hass,
name,
port,
ip_address,
entity_filter,
exclude_accessory_mode,
entity_config,
homekit_mode,
advertise_ip,
entry.entry_id,
entry.title,
devices=devices,
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, homekit.async_stop)
)
hass.data[DOMAIN][entry.entry_id] = {HOMEKIT: homekit}
if hass.state == CoreState.running:
await homekit.async_start()
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start)
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
if entry.source == SOURCE_IMPORT:
return
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
async_dismiss_setup_message(hass, entry.entry_id)
homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT]
if homekit.status == STATUS_RUNNING:
await homekit.async_stop()
logged_shutdown_wait = False
for _ in range(0, SHUTDOWN_TIMEOUT):
if async_port_is_available(entry.data[CONF_PORT]):
break
if not logged_shutdown_wait:
_LOGGER.info("Waiting for the HomeKit server to shutdown")
logged_shutdown_wait = True
await asyncio.sleep(PORT_CLEANUP_CHECK_INTERVAL_SECS)
hass.data[DOMAIN].pop(entry.entry_id)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Remove a config entry."""
return await hass.async_add_executor_job(
remove_state_files_for_entry_id, hass, entry.entry_id
)
@callback
def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
options = deepcopy(dict(entry.options))
data = deepcopy(dict(entry.data))
modified = False
for importable_option in CONFIG_OPTIONS:
if importable_option not in entry.options and importable_option in entry.data:
options[importable_option] = entry.data[importable_option]
del data[importable_option]
modified = True
if modified:
hass.config_entries.async_update_entry(entry, data=data, options=options)
@callback
def _async_register_events_and_services(hass: HomeAssistant):
"""Register events and services for HomeKit."""
hass.http.register_view(HomeKitPairingQRView)
async def async_handle_homekit_reset_accessory(service):
"""Handle reset accessory HomeKit service call."""
for homekit in _async_all_homekit_instances(hass):
if homekit.status != STATUS_RUNNING:
_LOGGER.warning(
"HomeKit is not running. Either it is waiting to be "
"started or has been stopped"
)
continue
entity_ids = service.data.get("entity_id")
await homekit.async_reset_accessories(entity_ids)
hass.services.async_register(
DOMAIN,
SERVICE_HOMEKIT_RESET_ACCESSORY,
async_handle_homekit_reset_accessory,
schema=RESET_ACCESSORY_SERVICE_SCHEMA,
)
async def async_handle_homekit_unpair(service):
"""Handle unpair HomeKit service call."""
referenced = async_extract_referenced_entity_ids(hass, service)
dev_reg = device_registry.async_get(hass)
for device_id in referenced.referenced_devices:
if not (dev_reg_ent := dev_reg.async_get(device_id)):
raise HomeAssistantError(f"No device found for device id: {device_id}")
macs = [
cval
for ctype, cval in dev_reg_ent.connections
if ctype == device_registry.CONNECTION_NETWORK_MAC
]
matching_instances = [
homekit
for homekit in _async_all_homekit_instances(hass)
if homekit.driver
and device_registry.format_mac(homekit.driver.state.mac) in macs
]
if not matching_instances:
raise HomeAssistantError(
f"No homekit accessory found for device id: {device_id}"
)
for homekit in matching_instances:
homekit.async_unpair()
hass.services.async_register(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
async_handle_homekit_unpair,
schema=UNPAIR_SERVICE_SCHEMA,
)
async def async_handle_homekit_service_start(service):
"""Handle start HomeKit service call."""
tasks = []
for homekit in _async_all_homekit_instances(hass):
if homekit.status == STATUS_RUNNING:
_LOGGER.debug("HomeKit is already running")
continue
if homekit.status != STATUS_READY:
_LOGGER.warning(
"HomeKit is not ready. Either it is already starting up or has "
"been stopped"
)
continue
tasks.append(homekit.async_start())
await asyncio.gather(*tasks)
hass.services.async_register(
DOMAIN, SERVICE_HOMEKIT_START, async_handle_homekit_service_start
)
async def _handle_homekit_reload(service):
"""Handle start HomeKit service call."""
config = await async_integration_yaml_config(hass, DOMAIN)
if not config or DOMAIN not in config:
return
current_entries = hass.config_entries.async_entries(DOMAIN)
entries_by_name = _async_get_entries_by_name(current_entries)
for conf in config[DOMAIN]:
_async_update_config_entry_if_from_yaml(hass, entries_by_name, conf)
reload_tasks = [
hass.config_entries.async_reload(entry.entry_id)
for entry in current_entries
]
await asyncio.gather(*reload_tasks)
hass.helpers.service.async_register_admin_service(
DOMAIN,
SERVICE_RELOAD,
_handle_homekit_reload,
)
class HomeKit:
"""Class to handle all actions between HomeKit and Home Assistant."""
def __init__(
self,
hass,
name,
port,
ip_address,
entity_filter,
exclude_accessory_mode,
entity_config,
homekit_mode,
advertise_ip=None,
entry_id=None,
entry_title=None,
devices=None,
):
"""Initialize a HomeKit object."""
self.hass = hass
self._name = name
self._port = port
self._ip_address = ip_address
self._filter = entity_filter
self._config = entity_config
self._exclude_accessory_mode = exclude_accessory_mode
self._advertise_ip = advertise_ip
self._entry_id = entry_id
self._entry_title = entry_title
self._homekit_mode = homekit_mode
self._devices = devices or []
self.aid_storage = None
self.status = STATUS_READY
self.bridge = None
self.driver = None
def setup(self, async_zeroconf_instance, uuid):
"""Set up bridge and accessory driver."""
persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id)
self.driver = HomeDriver(
self.hass,
self._entry_id,
self._name,
self._entry_title,
loop=self.hass.loop,
address=self._ip_address,
port=self._port,
persist_file=persist_file,
advertised_address=self._advertise_ip,
async_zeroconf_instance=async_zeroconf_instance,
zeroconf_server=f"{uuid}-hap.local.",
)
# If we do not load the mac address will be wrong
# as pyhap uses a random one until state is restored
if os.path.exists(persist_file):
self.driver.load()
async def async_reset_accessories(self, entity_ids):
"""Reset the accessory to load the latest configuration."""
if not self.bridge:
await self.async_reset_accessories_in_accessory_mode(entity_ids)
return
await self.async_reset_accessories_in_bridge_mode(entity_ids)
async def async_reset_accessories_in_accessory_mode(self, entity_ids):
"""Reset accessories in accessory mode."""
acc = self.driver.accessory
if acc.entity_id not in entity_ids:
return
await acc.stop()
if not (state := self.hass.states.get(acc.entity_id)):
_LOGGER.warning(
"The underlying entity %s disappeared during reset", acc.entity
)
return
if new_acc := self._async_create_single_accessory([state]):
self.driver.accessory = new_acc
self.hass.async_add_job(new_acc.run)
await self.async_config_changed()
async def async_reset_accessories_in_bridge_mode(self, entity_ids):
"""Reset accessories in bridge mode."""
new = []
for entity_id in entity_ids:
aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
if aid not in self.bridge.accessories:
continue
_LOGGER.info(
"HomeKit Bridge %s will reset accessory with linked entity_id %s",
self._name,
entity_id,
)
acc = await self.async_remove_bridge_accessory(aid)
if state := self.hass.states.get(acc.entity_id):
new.append(state)
else:
_LOGGER.warning(
"The underlying entity %s disappeared during reset", acc.entity
)
if not new:
# No matched accessories, probably on another bridge
return
await self.async_config_changed()
await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME)
for state in new:
acc = self.add_bridge_accessory(state)
if acc:
self.hass.async_add_job(acc.run)
await self.async_config_changed()
async def async_config_changed(self):
"""Call config changed which writes out the new config to disk."""
await self.hass.async_add_executor_job(self.driver.config_changed)
def add_bridge_accessory(self, state):
"""Try adding accessory to bridge if configured beforehand."""
if self._would_exceed_max_devices(state.entity_id):
return
if state_needs_accessory_mode(state):
if self._exclude_accessory_mode:
return
_LOGGER.warning(
"The bridge %s has entity %s. For best performance, "
"and to prevent unexpected unavailability, create and "
"pair a separate HomeKit instance in accessory mode for "
"this entity",
self._name,
state.entity_id,
)
aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id)
conf = self._config.get(state.entity_id, {}).copy()
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
# the rest of the accessories from being created
try:
acc = get_accessory(self.hass, self.driver, state, aid, conf)
if acc is not None:
self.bridge.add_accessory(acc)
return acc
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Failed to create a HomeKit accessory for %s", state.entity_id
)
return None
def _would_exceed_max_devices(self, name):
"""Check if adding another devices would reach the limit and log."""
# The bridge itself counts as an accessory
if len(self.bridge.accessories) + 1 >= MAX_DEVICES:
_LOGGER.warning(
"Cannot add %s as this would exceed the %d device limit. Consider using the filter option",
name,
MAX_DEVICES,
)
return True
return False
def add_bridge_triggers_accessory(self, device, device_triggers):
"""Add device automation triggers to the bridge."""
if self._would_exceed_max_devices(device.name):
return
aid = self.aid_storage.get_or_allocate_aid(device.id, device.id)
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
# the rest of the accessories from being created
config = {}
self._fill_config_from_device_registry_entry(device, config)
self.bridge.add_accessory(
DeviceTriggerAccessory(
self.hass,
self.driver,
device.name,
None,
aid,
config,
device_id=device.id,
device_triggers=device_triggers,
)
)
async def async_remove_bridge_accessory(self, aid):
"""Try adding accessory to bridge if configured beforehand."""
if acc := self.bridge.accessories.pop(aid, None):
await acc.stop()
return acc
async def async_configure_accessories(self):
"""Configure accessories for the included states."""
dev_reg = device_registry.async_get(self.hass)
ent_reg = entity_registry.async_get(self.hass)
device_lookup = ent_reg.async_get_device_class_lookup(
{
(BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING),
(BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION),
(BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY),
(SENSOR_DOMAIN, DEVICE_CLASS_BATTERY),
(SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY),
}
)
entity_states = []
for state in self.hass.states.async_all():
entity_id = state.entity_id
if not self._filter(entity_id):
continue
if ent_reg_ent := ent_reg.async_get(entity_id):
await self._async_set_device_info_attributes(
ent_reg_ent, dev_reg, entity_id
)
self._async_configure_linked_sensors(ent_reg_ent, device_lookup, state)
entity_states.append(state)
return entity_states
async def async_start(self, *args):
"""Load storage and start."""
if self.status != STATUS_READY:
return
self.status = STATUS_WAIT
async_zc_instance = await zeroconf.async_get_async_instance(self.hass)
uuid = await self.hass.helpers.instance_id.async_get()
await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid)
self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id)
await self.aid_storage.async_initialize()
if not await self._async_create_accessories():
return
self._async_register_bridge()
_LOGGER.debug("Driver start for %s", self._name)
await self.driver.async_start()
async with self.hass.data[DOMAIN][PERSIST_LOCK]:
await self.hass.async_add_executor_job(self.driver.persist)
self.status = STATUS_RUNNING
if self.driver.state.paired:
return
self._async_show_setup_message()
@callback
def _async_show_setup_message(self):
"""Show the pairing setup message."""
async_show_setup_message(
self.hass,
self._entry_id,
accessory_friendly_name(self._entry_title, self.driver.accessory),
self.driver.state.pincode,
self.driver.accessory.xhm_uri(),
)
@callback
def async_unpair(self):
"""Remove all pairings for an accessory so it can be repaired."""
state = self.driver.state
for client_uuid in list(state.paired_clients):
# We need to check again since removing a single client
# can result in removing all the clients that the client
# granted access to if it was an admin, otherwise
# remove_paired_client can generate a KeyError
if client_uuid in state.paired_clients:
state.remove_paired_client(client_uuid)
self.driver.async_persist()
self.driver.async_update_advertisement()
self._async_show_setup_message()
@callback
def _async_register_bridge(self):
"""Register the bridge as a device so homekit_controller and exclude it from discovery."""
dev_reg = device_registry.async_get(self.hass)
formatted_mac = device_registry.format_mac(self.driver.state.mac)
# Connections and identifiers are both used here.
#
# connections exists so homekit_controller can know the
# virtual mac address of the bridge and know to not offer
# it via discovery.
#
# identifiers is used as well since the virtual mac may change
# because it will not survive manual pairing resets (deleting state file)
# which we have trained users to do over the past few years
# because this was the way you had to fix homekit when pairing
# failed.
#
connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac)
identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER)
self._async_purge_old_bridges(dev_reg, identifier, connection)
is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY
hk_mode_name = "Accessory" if is_accessory_mode else "Bridge"
dev_reg.async_get_or_create(
config_entry_id=self._entry_id,
identifiers={identifier},
connections={connection},
manufacturer=MANUFACTURER,
name=accessory_friendly_name(self._entry_title, self.driver.accessory),
model=f"HomeKit {hk_mode_name}",
entry_type=device_registry.DeviceEntryType.SERVICE,
)
@callback
def _async_purge_old_bridges(self, dev_reg, identifier, connection):
"""Purge bridges that exist from failed pairing or manual resets."""
devices_to_purge = []
for entry in dev_reg.devices.values():
if self._entry_id in entry.config_entries and (
identifier not in entry.identifiers
or connection not in entry.connections
):
devices_to_purge.append(entry.id)
for device_id in devices_to_purge:
dev_reg.async_remove_device(device_id)
@callback
def _async_create_single_accessory(self, entity_states):
"""Create a single HomeKit accessory (accessory mode)."""
if not entity_states:
_LOGGER.error(
"HomeKit %s cannot startup: entity not available: %s",
self._name,
self._filter.config,
)
return None
state = entity_states[0]
conf = self._config.get(state.entity_id, {}).copy()
acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf)
if acc is None:
_LOGGER.error(
"HomeKit %s cannot startup: entity not supported: %s",
self._name,
self._filter.config,
)
return acc
async def _async_create_bridge_accessory(self, entity_states):
"""Create a HomeKit bridge with accessories. (bridge mode)."""
self.bridge = HomeBridge(self.hass, self.driver, self._name)
for state in entity_states:
self.add_bridge_accessory(state)
dev_reg = device_registry.async_get(self.hass)
if self._devices:
valid_device_ids = []
for device_id in self._devices:
if not dev_reg.async_get(device_id):
_LOGGER.warning(
"HomeKit %s cannot add device %s because it is missing from the device registry",
self._name,
device_id,
)
else:
valid_device_ids.append(device_id)
for device_id, device_triggers in (
await device_automation.async_get_device_automations(
self.hass, "trigger", valid_device_ids
)
).items():
self.add_bridge_triggers_accessory(
dev_reg.async_get(device_id), device_triggers
)
return self.bridge
async def _async_create_accessories(self):
"""Create the accessories."""
entity_states = await self.async_configure_accessories()
if self._homekit_mode == HOMEKIT_MODE_ACCESSORY:
acc = self._async_create_single_accessory(entity_states)
else:
acc = await self._async_create_bridge_accessory(entity_states)
if acc is None:
return False
# No need to load/persist as we do it in setup
self.driver.accessory = acc
return True
async def async_stop(self, *args):
"""Stop the accessory driver."""
if self.status != STATUS_RUNNING:
return
self.status = STATUS_STOPPED
_LOGGER.debug("Driver stop for %s", self._name)
await self.driver.async_stop()
@callback
def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state):
if (
ent_reg_ent is None
or ent_reg_ent.device_id is None
or ent_reg_ent.device_id not in device_lookup
or (ent_reg_ent.device_class or ent_reg_ent.original_device_class)
in (DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY)
):
return
if ATTR_BATTERY_CHARGING not in state.attributes:
battery_charging_binary_sensor_entity_id = device_lookup[
ent_reg_ent.device_id
].get((BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING))
if battery_charging_binary_sensor_entity_id:
self._config.setdefault(state.entity_id, {}).setdefault(
CONF_LINKED_BATTERY_CHARGING_SENSOR,
battery_charging_binary_sensor_entity_id,
)
if ATTR_BATTERY_LEVEL not in state.attributes:
battery_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get(
(SENSOR_DOMAIN, DEVICE_CLASS_BATTERY)
)
if battery_sensor_entity_id:
self._config.setdefault(state.entity_id, {}).setdefault(
CONF_LINKED_BATTERY_SENSOR, battery_sensor_entity_id
)
if state.entity_id.startswith(f"{CAMERA_DOMAIN}."):
motion_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get(
(BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION)
)
if motion_binary_sensor_entity_id:
self._config.setdefault(state.entity_id, {}).setdefault(
CONF_LINKED_MOTION_SENSOR,
motion_binary_sensor_entity_id,
)
doorbell_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get(
(BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY)
)
if doorbell_binary_sensor_entity_id:
self._config.setdefault(state.entity_id, {}).setdefault(
CONF_LINKED_DOORBELL_SENSOR,
doorbell_binary_sensor_entity_id,
)
if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."):
current_humidity_sensor_entity_id = device_lookup[
ent_reg_ent.device_id
].get((SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY))
if current_humidity_sensor_entity_id:
self._config.setdefault(state.entity_id, {}).setdefault(
CONF_LINKED_HUMIDITY_SENSOR,
current_humidity_sensor_entity_id,
)
async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_id):
"""Set attributes that will be used for homekit device info."""
ent_cfg = self._config.setdefault(entity_id, {})
if ent_reg_ent.device_id:
if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id):
self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg)
if ATTR_MANUFACTURER not in ent_cfg:
try:
integration = await async_get_integration(
self.hass, ent_reg_ent.platform
)
ent_cfg[ATTR_INTEGRATION] = integration.name
except IntegrationNotFound:
ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform
def _fill_config_from_device_registry_entry(self, device_entry, config):
"""Populate a config dict from the registry."""
if device_entry.manufacturer:
config[ATTR_MANUFACTURER] = device_entry.manufacturer
if device_entry.model:
config[ATTR_MODEL] = device_entry.model
if device_entry.sw_version:
config[ATTR_SW_VERSION] = device_entry.sw_version
if device_entry.config_entries:
first_entry = list(device_entry.config_entries)[0]
if entry := self.hass.config_entries.async_get_entry(first_entry):
config[ATTR_INTEGRATION] = entry.domain
class HomeKitPairingQRView(HomeAssistantView):
"""Display the homekit pairing code at a protected url."""
url = "/api/homekit/pairingqr"
name = "api:homekit:pairingqr"
requires_auth = False
async def get(self, request):
"""Retrieve the pairing QRCode image."""
# pylint: disable=no-self-use
if not request.query_string:
raise Unauthorized()
entry_id, secret = request.query_string.split("-")
if (
entry_id not in request.app["hass"].data[DOMAIN]
or secret
!= request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET]
):
raise Unauthorized()
return web.Response(
body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR],
content_type="image/svg+xml",
)