HAP-python 5.0.0__py3-none-any.whl → 5.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyhap/accessory_driver.py CHANGED
@@ -2,18 +2,19 @@
2
2
 
3
3
  Sending updates to clients
4
4
 
5
- The process of sending value changes to clients happens in two parts - on one hand, the
6
- value change is indicated by a Characteristic and, on the other, that change is sent to a
7
- client. To begin, typically, something in the Accessory's run method will do
5
+ The process of sending value changes to clients happens in two parts - on one hand,
6
+ the value change is indicated by a Characteristic and, on the other, that change is
7
+ sent to a client. To begin, typically, something in the Accessory's run method will do
8
8
  set_value(foo, notify=True) on one of its contained Characteristic. This in turn will
9
9
  create a HAP representation of the change and publish it to the Accessory. This will
10
10
  then add some more information and eventually the value change will reach the
11
11
  AccessoryDriver (all this happens through the publish() interface). The AccessoryDriver
12
12
  will then check if there is a client that subscribed for events from this exact
13
- Characteristic from this exact Accessory (remember, it could be a Bridge with more than
14
- one Accessory in it). If so, a task is created to send the event to the subscribers. This
15
- terminates the call chain and concludes the publishing process from the Characteristic,
16
- the Characteristic does not block waiting for the actual send to happen.
13
+ Characteristic from this exact Accessory (remember, it could be a Bridge with more
14
+ than one Accessory in it). If so, a task is created to send the event to the
15
+ subscribers. This terminates the call chain and concludes the publishing process
16
+ from the Characteristic, the Characteristic does not block waiting for the actual
17
+ send to happen.
17
18
  """
18
19
 
19
20
  import asyncio
@@ -28,13 +29,13 @@ import sys
28
29
  import tempfile
29
30
  import threading
30
31
  import time
31
- from typing import Any, Dict, List, Optional, Tuple
32
+ from typing import Any
32
33
 
33
34
  from zeroconf import ServiceInfo
34
35
  from zeroconf.asyncio import AsyncZeroconf
35
36
 
36
37
  from pyhap import util
37
- from pyhap.accessory import Accessory, get_topic
38
+ from pyhap.accessory import Accessory, Bridge, get_topic
38
39
  from pyhap.characteristic import Characteristic, CharacteristicError
39
40
  from pyhap.const import (
40
41
  HAP_PERMISSION_NOTIFY,
@@ -72,12 +73,23 @@ LEADING_TRAILING_SPACE_DASH = re.compile(r"^[ -]+|[ -]+$")
72
73
  DASH_REGEX = re.compile(r"[-]+")
73
74
  KEYS_TO_EXCLUDE = {HAP_REPR_IID, HAP_REPR_AID}
74
75
 
76
+ # Consecutive failed pair-setup proofs (each is a setup-code guess) before
77
+ # pair-setup is locked out, and for how long.
78
+ MAX_FAILED_PAIRING_ATTEMPTS = 5
79
+ PAIRING_LOCKOUT_SECONDS = 60
75
80
 
76
- def _wrap_char_setter(char, value, client_addr):
81
+ # Prepared writes are short-lived (HomeKit uses ~10 second TTLs); cap both
82
+ # the TTL and the number of pending writes per client so a paired peer cannot
83
+ # grow ``prepared_writes`` without bound.
84
+ MAX_PREPARED_WRITES_PER_CLIENT = 16
85
+ MAX_PREPARED_WRITE_TTL_SECONDS = 30.0
86
+
87
+
88
+ def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]:
77
89
  """Process an characteristic setter callback trapping and logging all exceptions."""
78
90
  try:
79
91
  response = char.client_update_value(value, client_addr)
80
- except Exception: # pylint: disable=broad-except
92
+ except Exception:
81
93
  logger.exception(
82
94
  "%s: Error while setting characteristic %s to %s",
83
95
  client_addr,
@@ -88,11 +100,11 @@ def _wrap_char_setter(char, value, client_addr):
88
100
  return HAP_SERVER_STATUS.SUCCESS, response
89
101
 
90
102
 
91
- def _wrap_acc_setter(acc, updates_by_service, client_addr):
103
+ def _wrap_acc_setter(acc: Any, updates_by_service: Any, client_addr: Any) -> Any:
92
104
  """Process an accessory setter callback trapping and logging all exceptions."""
93
105
  try:
94
106
  acc.setter_callback(updates_by_service)
95
- except Exception: # pylint: disable=broad-except
107
+ except Exception:
96
108
  logger.exception(
97
109
  "%s: Error while setting characteristics to %s for the %s accessory",
98
110
  updates_by_service,
@@ -103,7 +115,7 @@ def _wrap_acc_setter(acc, updates_by_service, client_addr):
103
115
  return HAP_SERVER_STATUS.SUCCESS
104
116
 
105
117
 
106
- def _wrap_service_setter(service, chars, client_addr):
118
+ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any:
107
119
  """Process a service setter callback trapping and logging all exceptions."""
108
120
  # Ideally this would pass the chars as is without converting
109
121
  # them to the display_name, but that would break existing
@@ -111,7 +123,7 @@ def _wrap_service_setter(service, chars, client_addr):
111
123
  service_chars = {char.display_name: value for char, value in chars.items()}
112
124
  try:
113
125
  service.setter_callback(service_chars)
114
- except Exception: # pylint: disable=broad-except
126
+ except Exception:
115
127
  logger.exception(
116
128
  "%s: Error while setting characteristics to %s for the %s service",
117
129
  service_chars,
@@ -125,7 +137,9 @@ def _wrap_service_setter(service, chars, client_addr):
125
137
  class AccessoryMDNSServiceInfo(ServiceInfo):
126
138
  """A mDNS service info representation of an accessory."""
127
139
 
128
- def __init__(self, accessory, state, zeroconf_server=None):
140
+ def __init__(
141
+ self, accessory: Any, state: State, zeroconf_server: Any = None
142
+ ) -> None:
129
143
  self.accessory = accessory
130
144
  self.state: State = state
131
145
 
@@ -147,14 +161,14 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
147
161
  parsed_addresses=self.state.addresses,
148
162
  )
149
163
 
150
- def _valid_name(self):
164
+ def _valid_name(self) -> str:
151
165
  return re.sub(
152
166
  LEADING_TRAILING_SPACE_DASH,
153
167
  "",
154
168
  re.sub(VALID_MDNS_REGEX, " ", self.accessory.display_name),
155
169
  )
156
170
 
157
- def _valid_host_name(self):
171
+ def _valid_host_name(self) -> str:
158
172
  return re.sub(
159
173
  DASH_REGEX,
160
174
  "-",
@@ -164,13 +178,13 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
164
178
  .strip("-"),
165
179
  )
166
180
 
167
- def _setup_hash(self):
181
+ def _setup_hash(self) -> str:
168
182
  setup_hash_material = self.state.setup_id + self.state.mac
169
183
  temp_hash = hashlib.sha512()
170
184
  temp_hash.update(setup_hash_material.encode())
171
185
  return base64.b64encode(temp_hash.digest()[:4]).decode()
172
186
 
173
- def _get_advert_data(self):
187
+ def _get_advert_data(self) -> dict[str, str]:
174
188
  """Generate advertisement data from the accessory."""
175
189
  return {
176
190
  "md": self._valid_name(),
@@ -190,8 +204,7 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
190
204
 
191
205
 
192
206
  class AccessoryDriver:
193
- """
194
- An AccessoryDriver mediates between incoming requests from the HAPServer and
207
+ """An AccessoryDriver mediates between incoming requests from the HAPServer and
195
208
  the Accessory.
196
209
 
197
210
  The driver starts and stops the HAPServer, the mDNS advertisements and responds
@@ -201,28 +214,28 @@ class AccessoryDriver:
201
214
  def __init__(
202
215
  self,
203
216
  *,
204
- address=None,
205
- port=51234,
206
- persist_file="accessory.state",
207
- pincode=None,
208
- encoder=None,
209
- loader=None,
210
- loop=None,
211
- mac=None,
212
- listen_address=None,
213
- advertised_address=None,
214
- interface_choice=None,
215
- async_zeroconf_instance=None,
216
- zeroconf_server=None,
217
- ):
218
- """
219
- Initialize a new AccessoryDriver object.
217
+ address: Any = None,
218
+ port: int = 51234,
219
+ persist_file: str = "accessory.state",
220
+ pincode: Any = None,
221
+ encoder: Any = None,
222
+ loader: Any = None,
223
+ loop: Any = None,
224
+ mac: Any = None,
225
+ listen_address: Any = None,
226
+ advertised_address: Any = None,
227
+ interface_choice: Any = None,
228
+ async_zeroconf_instance: Any = None,
229
+ zeroconf_server: Any = None,
230
+ advertise: bool = True,
231
+ ) -> None:
232
+ """Initialize a new AccessoryDriver object.
220
233
 
221
234
  :param pincode: The pincode that HAP clients must prove they know in order
222
235
  to pair with this `Accessory`. Defaults to None, in which case a random
223
236
  pincode is generated. The pincode has the format "xxx-xx-xxx", where x is
224
237
  a digit.
225
- :type pincode: bytearray
238
+ :type pincode: bytes
226
239
 
227
240
  :param port: The local port on which the accessory will be accessible.
228
241
  In other words, this is the port of the HAPServer.
@@ -257,13 +270,18 @@ class AccessoryDriver:
257
270
  :param interface_choice: The zeroconf interfaces to listen on.
258
271
  :type InterfacesType: [InterfaceChoice.Default, InterfaceChoice.All]
259
272
 
260
- :param async_zeroconf_instance: An AsyncZeroconf instance. When running multiple accessories or
261
- bridges a single zeroconf instance can be shared to avoid the overhead
262
- of processing the same data multiple times.
273
+ :param async_zeroconf_instance: An AsyncZeroconf instance. When running multiple
274
+ accessories or bridges a single zeroconf instance can be shared to avoid
275
+ the overhead of processing the same data multiple times.
263
276
 
264
277
  :param zeroconf_server: The server name that will be used for the zeroconf
265
278
  ServiceInfo.
266
279
  :type zeroconf_server: str
280
+
281
+ :param advertise: Whether to advertise the accessory via mDNS when
282
+ the driver starts. Pass False to run the HAP server without
283
+ mDNS advertising, e.g. for tests or benchmarks.
284
+ :type advertise: bool
267
285
  """
268
286
  if loop is None:
269
287
  if sys.platform == "win32":
@@ -271,11 +289,9 @@ class AccessoryDriver:
271
289
  else:
272
290
  loop = asyncio.new_event_loop()
273
291
 
274
- executor_opts = {"max_workers": None}
275
- if sys.version_info >= (3, 6):
276
- executor_opts["thread_name_prefix"] = "SyncWorker"
277
-
278
- self.executor = ThreadPoolExecutor(**executor_opts)
292
+ self.executor = ThreadPoolExecutor(
293
+ max_workers=None, thread_name_prefix="SyncWorker"
294
+ )
279
295
  loop.set_default_executor(self.executor)
280
296
  self.tid = threading.current_thread()
281
297
  else:
@@ -284,8 +300,9 @@ class AccessoryDriver:
284
300
 
285
301
  self.loop = loop
286
302
 
287
- self.accessory: Optional[Accessory] = None
288
- self.advertiser = async_zeroconf_instance
303
+ self.accessory: Accessory | None = None
304
+ self.advertise = advertise
305
+ self.advertiser: AsyncZeroconf | None = async_zeroconf_instance
289
306
  self.zeroconf_server = zeroconf_server
290
307
  self.interface_choice = interface_choice
291
308
 
@@ -293,13 +310,20 @@ class AccessoryDriver:
293
310
  self.encoder = encoder or AccessoryEncoder()
294
311
  self.topics = {} # topic: set of (address, port) of subscribed clients
295
312
  self.loader = loader or Loader()
296
- self.aio_stop_event = None
313
+ self.aio_stop_event: asyncio.Event | None = None
297
314
  self.stop_event = threading.Event()
298
315
 
299
316
  self.safe_mode = False
300
317
 
301
- self.mdns_service_info = None
302
- self.srp_verifier = None
318
+ self.mdns_service_info: ServiceInfo | None = None
319
+ self._srp_verifier: SrpServer | None = None
320
+
321
+ # Pair-setup rate limiting: each M1 regenerates a 3072-bit SRP
322
+ # verifier and each failed proof is a pincode guess, so excess
323
+ # failures lock pair-setup out for a while (see
324
+ # ``register_failed_pairing_attempt``).
325
+ self._failed_pairing_attempts = 0
326
+ self._pairing_lockout_until = 0.0
303
327
 
304
328
  address = address or util.get_local_address()
305
329
  advertised_address = advertised_address or address
@@ -307,12 +331,16 @@ class AccessoryDriver:
307
331
  address=advertised_address, mac=mac, pincode=pincode, port=port
308
332
  )
309
333
 
310
- listen_address = listen_address or address
311
- network_tuple = (listen_address, self.state.port)
334
+ self._listen_address: str | list[str] = listen_address or address
335
+ network_tuple = (self._listen_address, self.state.port)
312
336
  self.http_server = HAPServer(network_tuple, self)
313
337
  self.prepared_writes = {}
314
338
 
315
- def start(self):
339
+ def listen_address(self) -> str | list[str]:
340
+ """Return the address, or list of addresses, the HAP server listens on."""
341
+ return self._listen_address
342
+
343
+ def start(self) -> None:
316
344
  """Start the event loop and call `start_service`.
317
345
 
318
346
  Pyhap will be stopped gracefully on a KeyBoardInterrupt.
@@ -322,11 +350,16 @@ class AccessoryDriver:
322
350
  if (
323
351
  threading.current_thread() is threading.main_thread()
324
352
  and os.name != "nt"
353
+ # The child watcher API was removed in Python 3.14; there the
354
+ # default event loop reaps subprocesses without one.
355
+ and hasattr(asyncio, "SafeChildWatcher")
325
356
  ):
326
357
  logger.debug("Setting child watcher")
327
- watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class
358
+ watcher = asyncio.SafeChildWatcher()
328
359
  watcher.attach_loop(self.loop)
329
- asyncio.set_child_watcher(watcher)
360
+ # Deprecated in 3.12, removed in 3.14; the hasattr guard
361
+ # above keeps this branch unreachable on 3.14+.
362
+ asyncio.set_child_watcher(watcher) # ty: ignore[deprecated]
330
363
  else:
331
364
  logger.debug(
332
365
  "Not setting a child watcher. Set one if "
@@ -342,20 +375,19 @@ class AccessoryDriver:
342
375
  self.loop.close()
343
376
  logger.info("Closed the event loop")
344
377
 
345
- def start_service(self):
378
+ def start_service(self) -> None:
346
379
  """Start the service."""
347
380
  self._validate_start()
348
381
  self.add_job(self.async_start)
349
382
 
350
- def _validate_start(self):
383
+ def _validate_start(self) -> None:
351
384
  """Validate we can start."""
352
385
  if self.accessory is None:
353
386
  raise ValueError(
354
- "You must assign an accessory to the driver, "
355
- "before you can start it."
387
+ "You must assign an accessory to the driver, before you can start it."
356
388
  )
357
389
 
358
- async def async_start(self):
390
+ async def async_start(self) -> None:
359
391
  """Starts the accessory.
360
392
 
361
393
  - Call the accessory's run method.
@@ -368,6 +400,7 @@ class AccessoryDriver:
368
400
  daemon.
369
401
  """
370
402
  self._validate_start()
403
+ assert self.accessory is not None # noqa: S101
371
404
  self.aio_stop_event = asyncio.Event()
372
405
 
373
406
  logger.info(
@@ -388,20 +421,21 @@ class AccessoryDriver:
388
421
  if self.state.set_accessories_hash(self.accessories_hash):
389
422
  self.async_persist()
390
423
 
391
- # Advertise the accessory as a mDNS service.
392
- logger.debug("Starting mDNS.")
393
- self.mdns_service_info = AccessoryMDNSServiceInfo(
394
- self.accessory, self.state, self.zeroconf_server
395
- )
424
+ if self.advertise:
425
+ # Advertise the accessory as a mDNS service.
426
+ logger.debug("Starting mDNS.")
427
+ self.mdns_service_info = AccessoryMDNSServiceInfo(
428
+ self.accessory, self.state, self.zeroconf_server
429
+ )
396
430
 
397
- if not self.advertiser:
398
- zc_args = {}
399
- if self.interface_choice is not None:
400
- zc_args["interfaces"] = self.interface_choice
401
- self.advertiser = AsyncZeroconf(**zc_args)
402
- await self.advertiser.async_register_service(
403
- self.mdns_service_info, cooperating_responders=True
404
- )
431
+ if not self.advertiser:
432
+ zc_args = {}
433
+ if self.interface_choice is not None:
434
+ zc_args["interfaces"] = self.interface_choice
435
+ self.advertiser = AsyncZeroconf(**zc_args)
436
+ await self.advertiser.async_register_service(
437
+ self.mdns_service_info, cooperating_responders=True
438
+ )
405
439
 
406
440
  # Print accessory setup message
407
441
  if not self.state.paired:
@@ -414,17 +448,23 @@ class AccessoryDriver:
414
448
  "AccessoryDriver for %s started successfully", self.accessory.display_name
415
449
  )
416
450
 
417
- def stop(self):
451
+ def stop(self) -> None:
418
452
  """Method to stop pyhap."""
419
453
  self.add_job(self.async_stop)
420
454
 
421
- async def async_stop(self):
455
+ async def async_stop(self) -> None:
422
456
  """Stops the AccessoryDriver and shutdown all remaining tasks."""
423
457
  self.stop_event.set()
458
+ accessory = self.accessory
459
+ assert accessory is not None # noqa: S101
460
+ assert self.aio_stop_event is not None # noqa: S101
424
461
  logger.debug("Stopping HAP server and event sending")
425
- logger.debug("Stopping mDNS advertising for %s", self.accessory.display_name)
426
- await self.advertiser.async_unregister_service(self.mdns_service_info)
427
- await self.advertiser.async_close()
462
+ if self.advertise:
463
+ assert self.advertiser is not None # noqa: S101
464
+ assert self.mdns_service_info is not None # noqa: S101
465
+ logger.debug("Stopping mDNS advertising for %s", accessory.display_name)
466
+ await self.advertiser.async_unregister_service(self.mdns_service_info)
467
+ await self.advertiser.async_close()
428
468
 
429
469
  self.aio_stop_event.set()
430
470
 
@@ -432,15 +472,15 @@ class AccessoryDriver:
432
472
 
433
473
  logger.info(
434
474
  "Stopping accessory %s on address %s, port %s.",
435
- self.accessory.display_name,
475
+ accessory.display_name,
436
476
  self.state.addresses,
437
477
  self.state.port,
438
478
  )
439
479
 
440
- await self.async_add_job(self.accessory.stop)
480
+ await self.async_add_job(accessory.stop)
441
481
 
442
482
  logger.debug(
443
- "AccessoryDriver for %s stopped successfully", self.accessory.display_name
483
+ "AccessoryDriver for %s stopped successfully", accessory.display_name
444
484
  )
445
485
 
446
486
  # Executor=None means a loop wasn't passed in
@@ -451,14 +491,14 @@ class AccessoryDriver:
451
491
 
452
492
  logger.debug("Stop completed")
453
493
 
454
- def add_job(self, target, *args):
494
+ def add_job(self, target: Any, *args: Any) -> None:
455
495
  """Add job to executor pool."""
456
496
  if target is None:
457
497
  raise ValueError("Don't call add_job with None.")
458
498
  self.loop.call_soon_threadsafe(self.async_add_job, target, *args)
459
499
 
460
500
  @util.callback
461
- def async_add_job(self, target, *args):
501
+ def async_add_job(self, target: Any, *args: Any) -> Any:
462
502
  """Add job from within the event loop."""
463
503
  task = None
464
504
 
@@ -473,7 +513,7 @@ class AccessoryDriver:
473
513
 
474
514
  return task
475
515
 
476
- def add_accessory(self, accessory):
516
+ def add_accessory(self, accessory: Accessory) -> None:
477
517
  """Add top level accessory to driver."""
478
518
  self.accessory = accessory
479
519
  if accessory.aid is None:
@@ -483,12 +523,16 @@ class AccessoryDriver:
483
523
  if os.path.exists(self.persist_file):
484
524
  logger.info("Loading Accessory state from `%s`", self.persist_file)
485
525
  self.load()
526
+ # Accessories built before load() have not seen their stored state.
527
+ accessory.restore_accessory_state()
486
528
  else:
487
529
  logger.info("Storing Accessory state in `%s`", self.persist_file)
488
530
  self.persist()
489
531
 
490
532
  @util.callback
491
- def async_subscribe_client_topic(self, client, topic, subscribe=True):
533
+ def async_subscribe_client_topic(
534
+ self, client: Any, topic: str, subscribe: bool = True
535
+ ) -> None:
492
536
  """(Un)Subscribe the given client from the given topic.
493
537
 
494
538
  This method must be run in the event loop.
@@ -499,9 +543,9 @@ class AccessoryDriver:
499
543
  :param topic: The topic to which to subscribe.
500
544
  :type topic: str
501
545
 
502
- :param subscribe: Whether to subscribe or unsubscribe the client. Both subscribing
503
- an already subscribed client and unsubscribing a client that is not subscribed
504
- do nothing.
546
+ :param subscribe: Whether to subscribe or unsubscribe the client. Both
547
+ subscribing an already subscribed client and unsubscribing a client
548
+ that is not subscribed do nothing.
505
549
  :type subscribe: bool
506
550
  """
507
551
  if subscribe:
@@ -519,7 +563,7 @@ class AccessoryDriver:
519
563
  if not subscribed_clients:
520
564
  del self.topics[topic]
521
565
 
522
- def connection_lost(self, client):
566
+ def connection_lost(self, client: Any) -> None:
523
567
  """Called when a connection is lost to a client.
524
568
 
525
569
  This method must be run in the event loop.
@@ -538,7 +582,12 @@ class AccessoryDriver:
538
582
  self.async_subscribe_client_topic(client, topic, subscribe=False)
539
583
  self.prepared_writes.pop(client, None)
540
584
 
541
- def publish(self, data, sender_client_addr=None, immediate=False):
585
+ def publish(
586
+ self,
587
+ data: dict[str, Any],
588
+ sender_client_addr: Any = None,
589
+ immediate: bool = False,
590
+ ) -> None:
542
591
  """Publishes an event to the client.
543
592
 
544
593
  The publishing occurs only if the current client is subscribed to the topic for
@@ -560,11 +609,18 @@ class AccessoryDriver:
560
609
  self.async_send_event, topic, data, sender_client_addr, immediate
561
610
  )
562
611
 
563
- def async_send_event(self, topic, data, sender_client_addr, immediate):
612
+ def async_send_event(
613
+ self,
614
+ topic: str,
615
+ data: dict[str, Any],
616
+ sender_client_addr: Any,
617
+ immediate: bool,
618
+ ) -> None:
564
619
  """Send an event to a client.
565
620
 
566
621
  Must be called in the event loop
567
622
  """
623
+ assert self.aio_stop_event is not None # noqa: S101
568
624
  if self.aio_stop_event.is_set():
569
625
  return
570
626
 
@@ -598,25 +654,29 @@ class AccessoryDriver:
598
654
  for client_addr in unsubs:
599
655
  self.async_subscribe_client_topic(client_addr, topic, False)
600
656
 
601
- def config_changed(self):
657
+ def config_changed(self) -> None:
602
658
  """Notify the driver that the accessory's configuration has changed.
603
659
 
604
660
  Persists the accessory, so that the new configuration is available on
605
- restart. Also, updates the mDNS advertisement, so that iOS clients know they need
606
- to fetch new data.
661
+ restart. Also, updates the mDNS advertisement, so that iOS clients know
662
+ they need to fetch new data.
607
663
  """
608
664
  self.state.increment_config_version()
609
665
  self.persist()
610
666
  self.update_advertisement()
611
667
 
612
- def update_advertisement(self):
668
+ def update_advertisement(self) -> None:
613
669
  """Updates the mDNS service info for the accessory."""
614
670
  self.loop.call_soon_threadsafe(self.async_update_advertisement)
615
671
 
616
672
  @callback
617
- def async_update_advertisement(self):
673
+ def async_update_advertisement(self) -> None:
618
674
  """Updates the mDNS service info for the accessory from the event loop."""
619
675
  logger.debug("Updating mDNS advertisement")
676
+ if not self.advertise:
677
+ logger.debug("Not updating mDNS advertisement: advertising is disabled")
678
+ return
679
+ assert self.advertiser is not None # noqa: S101
620
680
  self.mdns_service_info = AccessoryMDNSServiceInfo(
621
681
  self.accessory, self.state, self.zeroconf_server
622
682
  )
@@ -624,17 +684,24 @@ class AccessoryDriver:
624
684
  self.advertiser.async_update_service(self.mdns_service_info)
625
685
  )
626
686
 
687
+ def accessory_state(self, aid: int) -> dict[str, Any]:
688
+ """Return the persisted state dict of accessory ``aid``.
689
+
690
+ Accessories keep settings here that must survive a restart. Save
691
+ changes with :meth:`async_persist`.
692
+ """
693
+ return self.state.accessory_states.setdefault(aid, {})
694
+
627
695
  @callback
628
- def async_persist(self):
696
+ def async_persist(self) -> None:
629
697
  """Saves the state of the accessory.
630
698
 
631
699
  Must be run in the event loop.
632
700
  """
633
- loop = asyncio.get_event_loop()
634
701
  logger.debug("Scheduling write of accessory state to disk")
635
- util.async_create_background_task(loop.run_in_executor(None, self.persist))
702
+ util.async_create_background_task(self.loop.run_in_executor(None, self.persist))
636
703
 
637
- def persist(self):
704
+ def persist(self) -> None:
638
705
  """Saves the state of the accessory.
639
706
 
640
707
  Must run in executor.
@@ -661,19 +728,19 @@ class AccessoryDriver:
661
728
  if os.path.exists(self.persist_file):
662
729
  os.chmod(self.persist_file, 0o644)
663
730
  os.replace(tmp_filename, self.persist_file)
664
- except Exception: # pylint: disable=broad-except
731
+ except Exception:
665
732
  logger.exception("Failed to persist accessory state")
666
733
  raise
667
734
  finally:
668
735
  if tmp_filename and os.path.exists(tmp_filename):
669
736
  os.remove(tmp_filename)
670
737
 
671
- def load(self):
738
+ def load(self) -> None:
672
739
  """Load the persist file.
673
740
 
674
741
  Must run in executor.
675
742
  """
676
- with open(self.persist_file, "r", encoding="utf8") as file_handle:
743
+ with open(self.persist_file, encoding="utf8") as file_handle:
677
744
  self.encoder.load_into(file_handle, self.state)
678
745
 
679
746
  @callback
@@ -704,6 +771,9 @@ class AccessoryDriver:
704
771
  client_username_bytes,
705
772
  client_permissions,
706
773
  )
774
+ # A successful pairing clears the rate limiter.
775
+ self._failed_pairing_attempts = 0
776
+ self._pairing_lockout_until = 0.0
707
777
  self.state.add_paired_client(
708
778
  client_username_bytes, client_public, client_permissions
709
779
  )
@@ -711,7 +781,32 @@ class AccessoryDriver:
711
781
  return True
712
782
 
713
783
  @callback
714
- def unpair(self, client_uuid):
784
+ def register_failed_pairing_attempt(self) -> None:
785
+ """Register a failed pair-setup attempt.
786
+
787
+ Each failed SRP proof is effectively a setup-code guess and each pair
788
+ setup also regenerates a 3072-bit SRP verifier, so after too many
789
+ consecutive failures pair-setup is locked out for a cooldown period.
790
+
791
+ This method must be run in the event loop.
792
+ """
793
+ self._failed_pairing_attempts += 1
794
+ if self._failed_pairing_attempts >= MAX_FAILED_PAIRING_ATTEMPTS:
795
+ self._pairing_lockout_until = time.monotonic() + PAIRING_LOCKOUT_SECONDS
796
+ self._failed_pairing_attempts = 0
797
+ logger.warning(
798
+ "Pair-setup locked out for %s seconds after %s failed attempts",
799
+ PAIRING_LOCKOUT_SECONDS,
800
+ MAX_FAILED_PAIRING_ATTEMPTS,
801
+ )
802
+
803
+ @property
804
+ def pairing_locked_out(self) -> bool:
805
+ """Whether pair-setup is currently locked out due to failed attempts."""
806
+ return time.monotonic() < self._pairing_lockout_until
807
+
808
+ @callback
809
+ def unpair(self, client_uuid: Any) -> None:
715
810
  """Removes the paired client from the accessory.
716
811
 
717
812
  Persist the new accessory state.
@@ -721,9 +816,15 @@ class AccessoryDriver:
721
816
  """
722
817
  logger.info("Unpairing client %s.", client_uuid)
723
818
  self.state.remove_paired_client(client_uuid)
819
+ if not self.state.paired and self.accessory is not None:
820
+ try:
821
+ self.accessory.reset_accessory_state()
822
+ except Exception:
823
+ # The removed pairing must be saved regardless.
824
+ logger.warning("Failed to reset the accessory state", exc_info=True)
724
825
  self.async_persist()
725
826
 
726
- def finish_pair(self):
827
+ def finish_pair(self) -> None:
727
828
  """Finishing pairing or unpairing.
728
829
 
729
830
  Updates the accessory and updates the mDNS service.
@@ -743,15 +844,21 @@ class AccessoryDriver:
743
844
  if not self.safe_mode:
744
845
  self.update_advertisement()
745
846
 
746
- def setup_srp_verifier(self):
847
+ @property
848
+ def srp_verifier(self) -> SrpServer:
849
+ """Return the SRP verifier (created by setup_srp_verifier)."""
850
+ assert self._srp_verifier is not None # noqa: S101
851
+ return self._srp_verifier
852
+
853
+ def setup_srp_verifier(self) -> None:
747
854
  """Create an SRP verifier for the accessory's info."""
748
855
  # TODO: Move the below hard-coded values somewhere nice.
749
856
  ctx = get_srp_context(3072, hashlib.sha512, 16)
750
857
  verifier = SrpServer(ctx, b"Pair-Setup", self.state.pincode)
751
- self.srp_verifier = verifier
858
+ self._srp_verifier = verifier
752
859
 
753
860
  @property
754
- def accessories_hash(self):
861
+ def accessories_hash(self) -> str:
755
862
  """Hash the get_accessories response to track configuration changes."""
756
863
  # We pass include_value=False to avoid including the value
757
864
  # of the characteristics in the hash. This is because the
@@ -764,7 +871,7 @@ class AccessoryDriver:
764
871
  util.to_sorted_hap_json(self.get_accessories(include_value=False))
765
872
  ).hexdigest()
766
873
 
767
- def get_accessories(self, include_value: bool = True):
874
+ def get_accessories(self, include_value: bool = True) -> dict[str, Any]:
768
875
  """Returns the accessory in HAP format.
769
876
 
770
877
  :return: An example HAP representation is:
@@ -789,6 +896,7 @@ class AccessoryDriver:
789
896
 
790
897
  :rtype: dict
791
898
  """
899
+ assert self.accessory is not None # noqa: S101
792
900
  hap_rep = self.accessory.to_HAP(include_value=include_value)
793
901
  if not isinstance(hap_rep, list):
794
902
  hap_rep = [
@@ -797,7 +905,7 @@ class AccessoryDriver:
797
905
  logger.debug("Get accessories response: %s", hap_rep)
798
906
  return {HAP_REPR_ACCS: hap_rep}
799
907
 
800
- def get_characteristics(self, char_ids):
908
+ def get_characteristics(self, char_ids: Any) -> dict[str, Any]:
801
909
  """Returns values for the required characteristics.
802
910
 
803
911
  :param char_ids: A list of characteristic "paths", e.g. "1.2" is aid 1, iid 2.
@@ -817,6 +925,7 @@ class AccessoryDriver:
817
925
 
818
926
  :rtype: dict
819
927
  """
928
+ assert self.accessory is not None # noqa: S101
820
929
  chars = []
821
930
  for aid_iid in char_ids:
822
931
  aid, iid = (int(i) for i in aid_iid.split("."))
@@ -829,15 +938,25 @@ class AccessoryDriver:
829
938
  try:
830
939
  if aid == STANDALONE_AID:
831
940
  char = self.accessory.iid_manager.get_obj(iid)
832
- available = True
941
+ available = self.accessory.available
833
942
  else:
834
- acc = self.accessory.accessories.get(aid)
835
- if acc is None:
836
- continue
837
- available = acc.available
838
- char = acc.iid_manager.get_obj(iid)
839
-
840
- if available:
943
+ # Accessory IIDs other than the standalone one only exist
944
+ # on bridged accessories; anything else is unknown and
945
+ # keeps the per-characteristic failure status.
946
+ bridge = self.accessory
947
+ if isinstance(bridge, Bridge):
948
+ acc = bridge.accessories.get(aid)
949
+ if acc is None:
950
+ continue
951
+ char = acc.iid_manager.get_obj(iid)
952
+ available = acc.available
953
+ else:
954
+ char = None
955
+ available = False
956
+
957
+ # A characteristic IID may resolve to a Service or be unknown;
958
+ # only a Characteristic can report a value.
959
+ if available and isinstance(char, Characteristic):
841
960
  rep[HAP_REPR_VALUE] = char.get_value()
842
961
  rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS
843
962
  except CharacteristicError:
@@ -846,7 +965,7 @@ class AccessoryDriver:
846
965
  self.accessory.display_name,
847
966
  (aid, iid),
848
967
  )
849
- except Exception: # pylint: disable=broad-except
968
+ except Exception:
850
969
  logger.exception(
851
970
  "%s: Unexpected error getting value for characteristic %s.",
852
971
  self.accessory.display_name,
@@ -857,7 +976,26 @@ class AccessoryDriver:
857
976
  logger.debug("Get chars response: %s", chars)
858
977
  return {HAP_REPR_CHARS: chars}
859
978
 
860
- def set_characteristics(self, chars_query, client_addr):
979
+ def derive_session_key(
980
+ self, client_addr: tuple[str, int] | None, salt: bytes, info: bytes
981
+ ) -> bytes | None:
982
+ """Derive a key from the pair-verify session key of ``client_addr``.
983
+
984
+ For transports set up from the HAP session, e.g. HomeKit Data Stream.
985
+ Never returns the session key itself, see
986
+ :meth:`HAPServerProtocol.derive_key`. Returns ``None`` when the
987
+ connection is unknown or not encrypted.
988
+ """
989
+ if client_addr is None:
990
+ return None
991
+ connection = self.http_server.connections.get(client_addr)
992
+ if connection is None:
993
+ return None
994
+ return connection.derive_key(salt, info)
995
+
996
+ def set_characteristics(
997
+ self, chars_query: dict[str, Any], client_addr: tuple[str, int]
998
+ ) -> dict[str, Any] | None:
861
999
  """Called from ``HAPServerHandler`` when iOS configures the characteristics.
862
1000
 
863
1001
  :param chars_query: A configuration query. For example:
@@ -878,17 +1016,17 @@ class AccessoryDriver:
878
1016
  """
879
1017
  # TODO: Add support for chars that do no support notifications.
880
1018
 
881
- queries: List[Dict[str, Any]] = chars_query[HAP_REPR_CHARS]
1019
+ queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS]
882
1020
 
883
1021
  self._notify(queries, client_addr)
884
1022
 
885
- updates_by_accessories_services: Dict[
886
- Accessory, Dict[Service, Dict[Characteristic, Any]]
1023
+ updates_by_accessories_services: dict[
1024
+ Accessory, dict[Service, dict[Characteristic, Any]]
887
1025
  ] = defaultdict(lambda: defaultdict(dict))
888
- results: Dict[int, Dict[int, Dict[str, Any]]] = defaultdict(
1026
+ results: dict[int, dict[int, dict[str, Any]]] = defaultdict(
889
1027
  lambda: defaultdict(dict)
890
1028
  )
891
- char_to_iid: Dict[Characteristic, int] = {}
1029
+ char_to_iid: dict[Characteristic, int] = {}
892
1030
 
893
1031
  expired = False
894
1032
  if HAP_REPR_PID in chars_query:
@@ -897,6 +1035,7 @@ class AccessoryDriver:
897
1035
  expired = expire_time is None or time.time() > expire_time
898
1036
 
899
1037
  primary_accessory = self.accessory
1038
+ assert primary_accessory is not None # noqa: S101
900
1039
  primary_aid = primary_accessory.aid
901
1040
 
902
1041
  for query in queries:
@@ -911,8 +1050,17 @@ class AccessoryDriver:
911
1050
  if aid == primary_aid:
912
1051
  acc = primary_accessory
913
1052
  else:
914
- acc = self.accessory.accessories.get(aid)
1053
+ # Accessory IIDs other than the primary one only exist on
1054
+ # bridged accessories.
1055
+ bridge = self.accessory
1056
+ if not isinstance(bridge, Bridge):
1057
+ continue
1058
+ acc = bridge.accessories.get(aid)
1059
+ if acc is None:
1060
+ continue
915
1061
  char = acc.get_characteristic(aid, iid)
1062
+ if char is None:
1063
+ continue
916
1064
 
917
1065
  set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST
918
1066
  set_result_value = None
@@ -930,11 +1078,13 @@ class AccessoryDriver:
930
1078
  results[aid][iid] = result
931
1079
  char_to_iid[char] = iid
932
1080
  service = char.service
1081
+ assert service is not None # noqa: S101
933
1082
  updates_by_accessories_services[acc][service][char] = value
934
1083
 
935
1084
  # Proccess accessory and service level setter callbacks
936
1085
  for acc, updates_by_service in updates_by_accessories_services.items():
937
1086
  aid = acc.aid
1087
+ assert aid is not None # noqa: S101
938
1088
  aid_results = results[aid]
939
1089
 
940
1090
  # Accessory level setter callbacks
@@ -970,7 +1120,9 @@ class AccessoryDriver:
970
1120
 
971
1121
  return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None
972
1122
 
973
- def prepare(self, prepare_query, client_addr):
1123
+ def prepare(
1124
+ self, prepare_query: dict[str, Any], client_addr: tuple[str, int]
1125
+ ) -> dict[str, int]:
974
1126
  """Called from ``HAPServerHandler`` when iOS wants to prepare a write.
975
1127
 
976
1128
  :param prepare_query: A prepare query. For example:
@@ -987,15 +1139,26 @@ class AccessoryDriver:
987
1139
  try:
988
1140
  ttl = prepare_query[HAP_REPR_TTL]
989
1141
  pid = prepare_query[HAP_REPR_PID]
990
- self.prepared_writes.setdefault(client_addr, {})[pid] = time.time() + (
991
- ttl / 1000
992
- )
993
- except (KeyError, ValueError):
1142
+ now = time.time()
1143
+ pending = self.prepared_writes.setdefault(client_addr, {})
1144
+ # Prune expired entries and cap the number of pending writes so a
1145
+ # paired client cannot grow prepared_writes without bound by
1146
+ # registering endless unique pids with arbitrary TTLs.
1147
+ for stale_pid in [
1148
+ prepared_pid
1149
+ for prepared_pid, expires_at in pending.items()
1150
+ if expires_at <= now
1151
+ ]:
1152
+ del pending[stale_pid]
1153
+ if len(pending) >= MAX_PREPARED_WRITES_PER_CLIENT:
1154
+ del pending[min(pending, key=pending.get)]
1155
+ pending[pid] = now + min(ttl / 1000, MAX_PREPARED_WRITE_TTL_SECONDS)
1156
+ except (KeyError, TypeError, ValueError):
994
1157
  return {HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST}
995
1158
 
996
1159
  return {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS}
997
1160
 
998
- def signal_handler(self, _signal, _frame):
1161
+ def signal_handler(self, _signal: Any, _frame: Any) -> None:
999
1162
  """Stops the AccessoryDriver for a given signal.
1000
1163
 
1001
1164
  An AccessoryDriver can be registered as a signal handler with this method. For
@@ -1003,8 +1166,8 @@ class AccessoryDriver:
1003
1166
  >>> import signal
1004
1167
  >>> signal.signal(signal.SIGINT, anAccDriver.signal_handler)
1005
1168
 
1006
- Now, when the user hits Ctrl+C, the driver will stop its accessory, the HAP server
1007
- and everything else that needs stopping and will exit gracefully.
1169
+ Now, when the user hits Ctrl+C, the driver will stop its accessory, the
1170
+ HAP server and everything else that needs stopping and will exit gracefully.
1008
1171
  """
1009
1172
  try:
1010
1173
  self.stop()
@@ -1013,7 +1176,7 @@ class AccessoryDriver:
1013
1176
  raise
1014
1177
 
1015
1178
  def _notify(
1016
- self, queries: List[Dict[str, Any]], client_addr: Tuple[str, int]
1179
+ self, queries: list[dict[str, Any]], client_addr: tuple[str, int]
1017
1180
  ) -> None:
1018
1181
  """Notify the driver that the client has subscribed or unsubscribed."""
1019
1182
  for query in queries: