HAP-python 5.0.0__tar.gz → 5.1.0__tar.gz

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.
Files changed (60) hide show
  1. {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/PKG-INFO +4 -3
  2. {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/SOURCES.txt +8 -0
  3. {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/requires.txt +3 -0
  4. {hap_python-5.0.0 → hap_python-5.1.0}/PKG-INFO +4 -3
  5. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/accessory_driver.py +8 -1
  6. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/camera.py +317 -7
  7. hap_python-5.1.0/pyhap/camera_talkback.py +189 -0
  8. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/characteristic.py +51 -3
  9. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/const.py +1 -1
  10. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_protocol.py +5 -0
  11. hap_python-5.1.0/pyhap/hds.py +185 -0
  12. hap_python-5.1.0/pyhap/hds_protocol.py +307 -0
  13. hap_python-5.1.0/pyhap/hds_recording.py +263 -0
  14. hap_python-5.1.0/pyhap/hds_server.py +309 -0
  15. hap_python-5.1.0/pyhap/hksv_recording.py +364 -0
  16. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/characteristics.json +141 -2
  17. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/services.json +51 -0
  18. {hap_python-5.0.0 → hap_python-5.1.0}/pyproject.toml +4 -2
  19. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_accessory.py +3 -5
  20. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_accessory_driver.py +48 -3
  21. hap_python-5.1.0/tests/test_camera.py +307 -0
  22. hap_python-5.1.0/tests/test_hds_recording.py +449 -0
  23. hap_python-5.1.0/tests/test_hksv_recording.py +280 -0
  24. hap_python-5.0.0/tests/test_camera.py +0 -145
  25. {hap_python-5.0.0 → hap_python-5.1.0}/AUTHORS +0 -0
  26. {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/dependency_links.txt +0 -0
  27. {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/top_level.txt +0 -0
  28. {hap_python-5.0.0 → hap_python-5.1.0}/LICENSE +0 -0
  29. {hap_python-5.0.0 → hap_python-5.1.0}/MANIFEST.in +0 -0
  30. {hap_python-5.0.0 → hap_python-5.1.0}/README.md +0 -0
  31. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/__init__.py +0 -0
  32. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/accessory.py +0 -0
  33. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/encoder.py +0 -0
  34. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_crypto.py +0 -0
  35. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_event.py +0 -0
  36. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_handler.py +0 -0
  37. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_server.py +0 -0
  38. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hsrp.py +0 -0
  39. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/iid_manager.py +0 -0
  40. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/loader.py +0 -0
  41. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/params.py +0 -0
  42. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/snapshot.jpg +0 -0
  43. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/service.py +0 -0
  44. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/state.py +0 -0
  45. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/tlv.py +0 -0
  46. {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/util.py +0 -0
  47. {hap_python-5.0.0 → hap_python-5.1.0}/setup.cfg +0 -0
  48. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_characteristic.py +0 -0
  49. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_encoder.py +0 -0
  50. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_crypto.py +0 -0
  51. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_handler.py +0 -0
  52. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_protocol.py +0 -0
  53. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_server.py +0 -0
  54. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hsrp.py +0 -0
  55. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_iid_manager.py +0 -0
  56. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_loader.py +0 -0
  57. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_service.py +0 -0
  58. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_state.py +0 -0
  59. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_tlv.py +0 -0
  60. {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: HAP-python
3
- Version: 5.0.0
3
+ Version: 5.1.0
4
4
  Summary: HomeKit Accessory Protocol implementation in python
5
5
  Author: Ivan Kalchev
6
6
  License-Expression: Apache-2.0
@@ -12,14 +12,13 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: End Users/Desktop
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Home Automation
21
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.10
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  License-File: AUTHORS
@@ -32,6 +31,8 @@ Requires-Dist: h11
32
31
  Provides-Extra: qrcode
33
32
  Requires-Dist: base36; extra == "qrcode"
34
33
  Requires-Dist: pyqrcode; extra == "qrcode"
34
+ Provides-Extra: talkback
35
+ Requires-Dist: pylibsrtp; extra == "talkback"
35
36
  Dynamic: license-file
36
37
 
37
38
  [![PyPI version](https://badge.fury.io/py/HAP-python.svg)](https://badge.fury.io/py/HAP-python) [![Build Status](https://github.com/ikalchev/HAP-python/workflows/CI/badge.svg)](https://github.com/ikalchev/HAP-python) [![codecov](https://codecov.io/gh/ikalchev/HAP-python/branch/master/graph/badge.svg)](https://codecov.io/gh/ikalchev/HAP-python) [![Python Versions](https://img.shields.io/pypi/pyversions/HAP-python.svg)](https://pypi.python.org/pypi/HAP-python/) [![Documentation Status](https://readthedocs.org/projects/hap-python/badge/?version=latest)](http://hap-python.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/hap-python)](https://pepy.tech/project/hap-python)
@@ -13,6 +13,7 @@ pyhap/__init__.py
13
13
  pyhap/accessory.py
14
14
  pyhap/accessory_driver.py
15
15
  pyhap/camera.py
16
+ pyhap/camera_talkback.py
16
17
  pyhap/characteristic.py
17
18
  pyhap/const.py
18
19
  pyhap/encoder.py
@@ -21,6 +22,11 @@ pyhap/hap_event.py
21
22
  pyhap/hap_handler.py
22
23
  pyhap/hap_protocol.py
23
24
  pyhap/hap_server.py
25
+ pyhap/hds.py
26
+ pyhap/hds_protocol.py
27
+ pyhap/hds_recording.py
28
+ pyhap/hds_server.py
29
+ pyhap/hksv_recording.py
24
30
  pyhap/hsrp.py
25
31
  pyhap/iid_manager.py
26
32
  pyhap/loader.py
@@ -41,6 +47,8 @@ tests/test_hap_crypto.py
41
47
  tests/test_hap_handler.py
42
48
  tests/test_hap_protocol.py
43
49
  tests/test_hap_server.py
50
+ tests/test_hds_recording.py
51
+ tests/test_hksv_recording.py
44
52
  tests/test_hsrp.py
45
53
  tests/test_iid_manager.py
46
54
  tests/test_loader.py
@@ -10,3 +10,6 @@ async_timeout
10
10
  [QRCode]
11
11
  base36
12
12
  pyqrcode
13
+
14
+ [Talkback]
15
+ pylibsrtp
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: HAP-python
3
- Version: 5.0.0
3
+ Version: 5.1.0
4
4
  Summary: HomeKit Accessory Protocol implementation in python
5
5
  Author: Ivan Kalchev
6
6
  License-Expression: Apache-2.0
@@ -12,14 +12,13 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: End Users/Desktop
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Home Automation
21
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.10
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  License-File: AUTHORS
@@ -32,6 +31,8 @@ Requires-Dist: h11
32
31
  Provides-Extra: qrcode
33
32
  Requires-Dist: base36; extra == "qrcode"
34
33
  Requires-Dist: pyqrcode; extra == "qrcode"
34
+ Provides-Extra: talkback
35
+ Requires-Dist: pylibsrtp; extra == "talkback"
35
36
  Dynamic: license-file
36
37
 
37
38
  [![PyPI version](https://badge.fury.io/py/HAP-python.svg)](https://badge.fury.io/py/HAP-python) [![Build Status](https://github.com/ikalchev/HAP-python/workflows/CI/badge.svg)](https://github.com/ikalchev/HAP-python) [![codecov](https://codecov.io/gh/ikalchev/HAP-python/branch/master/graph/badge.svg)](https://codecov.io/gh/ikalchev/HAP-python) [![Python Versions](https://img.shields.io/pypi/pyversions/HAP-python.svg)](https://pypi.python.org/pypi/HAP-python/) [![Documentation Status](https://readthedocs.org/projects/hap-python/badge/?version=latest)](http://hap-python.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/hap-python)](https://pepy.tech/project/hap-python)
@@ -292,6 +292,9 @@ class AccessoryDriver:
292
292
  self.persist_file = os.path.expanduser(persist_file)
293
293
  self.encoder = encoder or AccessoryEncoder()
294
294
  self.topics = {} # topic: set of (address, port) of subscribed clients
295
+ # Per-session HAP shared keys, keyed by client (address, port). Needed
296
+ # by transports derived from the session, e.g. HomeKit Data Stream.
297
+ self.session_shared_keys: Dict[Tuple[str, int], bytes] = {}
295
298
  self.loader = loader or Loader()
296
299
  self.aio_stop_event = None
297
300
  self.stop_event = threading.Event()
@@ -322,6 +325,9 @@ class AccessoryDriver:
322
325
  if (
323
326
  threading.current_thread() is threading.main_thread()
324
327
  and os.name != "nt"
328
+ # The child watcher API was removed in Python 3.14; there the
329
+ # default event loop reaps subprocesses without one.
330
+ and hasattr(asyncio, "SafeChildWatcher")
325
331
  ):
326
332
  logger.debug("Setting child watcher")
327
333
  watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class
@@ -537,6 +543,7 @@ class AccessoryDriver:
537
543
  for topic in client_topics:
538
544
  self.async_subscribe_client_topic(client, topic, subscribe=False)
539
545
  self.prepared_writes.pop(client, None)
546
+ self.session_shared_keys.pop(client, None)
540
547
 
541
548
  def publish(self, data, sender_client_addr=None, immediate=False):
542
549
  """Publishes an event to the client.
@@ -829,7 +836,7 @@ class AccessoryDriver:
829
836
  try:
830
837
  if aid == STANDALONE_AID:
831
838
  char = self.accessory.iid_manager.get_obj(iid)
832
- available = True
839
+ available = self.accessory.available
833
840
  else:
834
841
  acc = self.accessory.accessories.get(aid)
835
842
  if acc is None:
@@ -25,10 +25,11 @@ import struct
25
25
  import sys
26
26
  from uuid import UUID
27
27
 
28
- from pyhap import RESOURCE_DIR, tlv
28
+ from pyhap import RESOURCE_DIR, hds, hds_recording, hds_server, hksv_recording, tlv
29
29
  from pyhap.accessory import Accessory
30
+ from pyhap.camera_talkback import TalkbackReceiver, talkback_available
30
31
  from pyhap.const import CATEGORY_CAMERA
31
- from pyhap.util import byte_bool, to_base64_str
32
+ from pyhap.util import base64_to_bytes, byte_bool, to_base64_str
32
33
 
33
34
  if sys.version_info >= (3, 11):
34
35
  from asyncio import timeout as async_timeout
@@ -427,6 +428,13 @@ class Camera(Accessory):
427
428
 
428
429
  Additional optional values are:
429
430
  - srtp - boolean, defaults to False. Whether the camera supports SRTP.
431
+ - talkback - boolean, defaults to False. Whether the camera supports
432
+ receiving two-way ("talkback") audio from the controller during a
433
+ live view session. Requires the optional ``pylibsrtp`` dependency
434
+ (``pip install HAP-python[Talkback]``). When enabled, a "Speaker"
435
+ service is added so that HAP clients know the accessory supports
436
+ it, and :meth:`talkback_audio_received` is called with the
437
+ received audio for each session that negotiates it.
430
438
  - start_stream_cmd - string specifying the command to be executed to start
431
439
  the stream. The string can contain the keywords, corresponding to the
432
440
  video and audio configuration that was negotiated between the camera
@@ -435,6 +443,12 @@ class Camera(Accessory):
435
443
  :type options: ``dict``
436
444
  """
437
445
  self.has_srtp = options.get("srtp", False)
446
+ self.support_talkback = options.get("talkback", False)
447
+ if self.support_talkback and not talkback_available():
448
+ raise ValueError(
449
+ "The 'talkback' option requires the optional 'pylibsrtp' "
450
+ "dependency. Install it with: pip install HAP-python[Talkback]"
451
+ )
438
452
  self.start_stream_cmd = options.get("start_stream_cmd", FFMPEG_CMD)
439
453
 
440
454
  self.stream_address = options["address"]
@@ -448,14 +462,29 @@ class Camera(Accessory):
448
462
  super().__init__(*args, **kwargs)
449
463
 
450
464
  self.add_preload_service("Microphone")
465
+ if self.support_talkback:
466
+ self.add_preload_service("Speaker").configure_char("Mute", value=False)
451
467
  self._streaming_status = []
452
468
  self._management = []
453
- self._setup_stream_management(options)
469
+ # All HKSV state defaults are initialized before any setup runs so the
470
+ # setup methods below never follow (and reset) them.
471
+ self._status_active_char = None
472
+ self._recording_service = None
473
+ self._selected_recording_config = None
474
+ self._motion_detected_char = None
475
+ self._hds_listener = None
476
+ self._hds_connections = set()
477
+ if options.get("video"):
478
+ self._setup_stream_management(options)
479
+ # Classic HomeKit Secure Video recording (HDS + fragmented MP4).
480
+ if options.get("recording"):
481
+ self._setup_recording_management(options)
454
482
 
455
483
  @property
456
484
  def streaming_status(self):
457
485
  """For backwards compatibility."""
458
- return self._streaming_status[0]
486
+ # A recording-only camera has no RTP stream management service.
487
+ return self._streaming_status[0] if self._streaming_status else None
459
488
 
460
489
  def _setup_stream_management(self, options):
461
490
  """Create stream management."""
@@ -467,8 +496,12 @@ class Camera(Accessory):
467
496
  def _create_stream_management(self, stream_idx, options):
468
497
  """Create a stream management service."""
469
498
  management = self.add_preload_service(
470
- "CameraRTPStreamManagement", unique_id=stream_idx
499
+ "CameraRTPStreamManagement", unique_id=stream_idx, chars=["Active"]
471
500
  )
501
+ # HAP-NodeJS exposes an Active (0x0B0) characteristic on every stream
502
+ # management service (value 1 = active); iOS' camera validation expects
503
+ # it, and it is required for Secure Video eligibility.
504
+ management.configure_char("Active", value=1)
472
505
  management.configure_char(
473
506
  "StreamingStatus",
474
507
  getter_callback=lambda: self._get_streaming_status(stream_idx),
@@ -497,6 +530,203 @@ class Camera(Accessory):
497
530
  )
498
531
  return management
499
532
 
533
+ def _default_recording_configs(self, options):
534
+ """Derive supported recording configurations from the streaming options.
535
+
536
+ A widely-compatible default: H.264 (Baseline/Main/High, levels 3.1/3.2/
537
+ 4.0) at the streaming resolutions, AAC-LC audio. Accessories can override
538
+ this to advertise their own recording capabilities.
539
+ """
540
+ video_opts = options.get("video") or {}
541
+ resolutions = video_opts.get("resolutions") or [[1920, 1080, 30]]
542
+ video = [
543
+ hksv_recording.VideoCodecConfiguration(
544
+ codec_type=hksv_recording.VideoCodecType.H264,
545
+ profile=[
546
+ VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"][0],
547
+ VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"][0],
548
+ VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["HIGH"][0],
549
+ ],
550
+ level=[
551
+ VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_1"][0],
552
+ VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_2"][0],
553
+ VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE4_0"][0],
554
+ ],
555
+ bitrate_kbps=2000,
556
+ iframe_interval_ms=4000,
557
+ attributes=[
558
+ hksv_recording.VideoAttributes(width, height, fps)
559
+ for width, height, fps in resolutions
560
+ ],
561
+ )
562
+ ]
563
+ audio = [hksv_recording.AudioCodecConfiguration()]
564
+ general = hksv_recording.SupportedRecordingConfiguration()
565
+ return general, video, audio
566
+
567
+ def _setup_recording_management(self, options):
568
+ """Create the Camera Recording Management service (classic HKSV).
569
+
570
+ Advertises the supported general/video/audio recording configurations
571
+ and accepts the controller's selection. Recording stays inactive
572
+ (Active=0) unless the ``recording`` option enables it; the fragment
573
+ data flow over HDS is wired by the recording transport.
574
+ """
575
+ # HKSV requires the Camera Operating Mode service (0000021A); the
576
+ # controller validates its presence before enabling recording.
577
+ operating_mode = self.add_preload_service(
578
+ "CameraOperatingMode", chars=["PeriodicSnapshotsActive"]
579
+ )
580
+ operating_mode.configure_char("EventSnapshotsActive", value=1)
581
+ operating_mode.configure_char("HomeKitCameraActive", value=1)
582
+ operating_mode.configure_char("PeriodicSnapshotsActive", value=1)
583
+
584
+ # HKSV records on an event trigger; the camera owns a motion sensor and
585
+ # links it (and the data stream transport) to the recording management.
586
+ motion = self.add_preload_service("MotionSensor", chars=["StatusActive"])
587
+ self._motion_detected_char = motion.configure_char(
588
+ "MotionDetected", value=False
589
+ )
590
+ # HAP-NodeJS marks the HKSV event-trigger sensor as active; iOS expects it.
591
+ motion.configure_char("StatusActive", value=True)
592
+
593
+ general, video, audio = self._default_recording_configs(options)
594
+ service = self.add_preload_service(
595
+ "CameraRecordingManagement", chars=["RecordingAudioActive"]
596
+ )
597
+ service.add_linked_service(motion)
598
+ service.configure_char("Active", value=0)
599
+ service.configure_char(
600
+ "RecordingAudioActive", value=1 if options.get("recording_audio") else 0
601
+ )
602
+ service.configure_char(
603
+ "SupportedCameraRecordingConfiguration",
604
+ value=to_base64_str(general.encode()),
605
+ )
606
+ service.configure_char(
607
+ "SupportedVideoRecordingConfiguration",
608
+ value=to_base64_str(hksv_recording.encode_supported_video(video)),
609
+ )
610
+ service.configure_char(
611
+ "SupportedAudioRecordingConfiguration",
612
+ value=to_base64_str(hksv_recording.encode_supported_audio(audio)),
613
+ )
614
+ service.configure_char(
615
+ "SelectedCameraRecordingConfiguration",
616
+ setter_callback=self.set_selected_recording_configuration,
617
+ )
618
+ self._recording_service = service
619
+ self._setup_data_stream_transport()
620
+
621
+ def _setup_data_stream_transport(self):
622
+ """Create the Data Stream Transport Management service used by HDS.
623
+
624
+ SetupDataStreamTransport needs the HAP session shared secret, so its
625
+ setter receives the sender's client address to look the secret up.
626
+ """
627
+ transport = self.add_preload_service("DataStreamTransportManagement")
628
+ transport.configure_char("Version", value="1.0")
629
+ transport.configure_char(
630
+ "SupportedDataStreamTransportConfiguration",
631
+ value=to_base64_str(self._supported_data_stream_transport()),
632
+ )
633
+ transport.configure_char(
634
+ "SetupDataStreamTransport",
635
+ setter_callback=self.set_data_stream_transport,
636
+ )
637
+ # HKSV requires the recording management service to link the data stream
638
+ # transport it records over; without the link the controller rejects
639
+ # recording during validation, before writing any configuration.
640
+ self._recording_service.add_linked_service(transport)
641
+
642
+ @staticmethod
643
+ def _supported_data_stream_transport():
644
+ # A single supported transport configuration: TCP.
645
+ transport_configuration = tlv.encode(b"\x01", hds.TRANSPORT_TYPE_TCP)
646
+ return tlv.encode(b"\x01", transport_configuration)
647
+
648
+ async def _ensure_hds_listener(self):
649
+ if self._hds_listener is None:
650
+ self._hds_listener = hds_server.HDSListener()
651
+ self._hds_listener.on_connection = self._on_hds_connection
652
+ await self._hds_listener.start()
653
+
654
+ def _on_hds_connection(self, connection):
655
+ self._hds_connections.add(connection)
656
+ connection.add_close_callback(self._hds_connections.discard)
657
+ hds_recording.RecordingStreamManager(connection, self._recording_delegate)
658
+
659
+ def set_data_stream_transport(self, value, sender_client_addr=None):
660
+ """Handle a write to SetupDataStreamTransport (HDS session setup).
661
+
662
+ SetupDataStreamTransport is a write-response ('wr') characteristic: the
663
+ controller writes with ``r: true`` and reads the encoded setup response
664
+ (TCP listening port + accessory key salt) straight back from the write
665
+ response. That means the value MUST be RETURNED from the setter so the
666
+ HAP layer includes it in the write response — setting it on the
667
+ characteristic is not delivered to the controller and leaves it without
668
+ the port, so it can never open the HDS connection.
669
+ """
670
+ request = hds.SetupRequest.decode(base64_to_bytes(value))
671
+ shared_secret = self.driver.session_shared_keys.get(sender_client_addr)
672
+ if shared_secret is None or self._hds_listener is None:
673
+ response = hds.encode_setup_response(
674
+ 0, b"", status=hds.SETUP_STATUS_GENERIC_ERROR
675
+ )
676
+ else:
677
+ accessory_salt = self._hds_listener.register_transport(
678
+ shared_secret, request.controller_key_salt
679
+ )
680
+ response = hds.encode_setup_response(
681
+ self._hds_listener.port, accessory_salt
682
+ )
683
+ return to_base64_str(response)
684
+
685
+ async def _recording_delegate(self, stream_id):
686
+ """Yield recording fragments for ``stream_id``.
687
+
688
+ Override ``handle_recording_stream`` to produce the fragmented MP4
689
+ packets; this adapter exists so the transport can await an async
690
+ generator.
691
+ """
692
+ async for packet in self.handle_recording_stream(stream_id):
693
+ yield packet
694
+
695
+ async def handle_recording_stream(self, stream_id):
696
+ """Produce the fragmented-MP4 recording for ``stream_id``.
697
+
698
+ Override to yield :class:`pyhap.hds_recording.RecordingPacket` objects,
699
+ the first being the MP4 initialization segment. The default yields
700
+ nothing (no recording).
701
+ """
702
+ return
703
+ yield # pragma: no cover - makes this an async generator
704
+
705
+ @property
706
+ def selected_recording_configuration(self):
707
+ """The controller's selected recording configuration, if any."""
708
+ return self._selected_recording_config
709
+
710
+ def set_selected_recording_configuration(self, value):
711
+ """Handle a write to Selected Camera Recording Configuration (spec HKSV)."""
712
+ self._selected_recording_config = (
713
+ hksv_recording.SelectedRecordingConfiguration.decode(base64_to_bytes(value))
714
+ )
715
+ self.recording_configuration_selected(self._selected_recording_config)
716
+
717
+ def recording_configuration_selected(self, configuration):
718
+ """React to the controller selecting a recording configuration. Override."""
719
+
720
+ def set_motion_detected(self, detected):
721
+ """Fire (``True``) or clear (``False``) the recording motion trigger.
722
+
723
+ This is the entry point an integration calls when its own motion
724
+ detector changes state: it drives the HKSV event-trigger MotionSensor,
725
+ which is what makes the Home Hub start (and later finish) a recording.
726
+ """
727
+ if self._motion_detected_char is not None:
728
+ self._motion_detected_char.set_value(bool(detected))
729
+
500
730
  async def _start_stream(self, objs, reconfigure): # pylint: disable=unused-argument
501
731
  """Start or reconfigure video streaming for the given session.
502
732
 
@@ -625,6 +855,7 @@ class Camera(Accessory):
625
855
  logger.error(
626
856
  "[%s] Failed to start/reconfigure stream, deleting session.", session_id
627
857
  )
858
+ self._close_talkback(session_info)
628
859
  del self.sessions[session_id]
629
860
  self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"]
630
861
 
@@ -657,10 +888,53 @@ class Camera(Accessory):
657
888
 
658
889
  stream_idx = session_info["stream_idx"]
659
890
  await self.stop_stream(session_info)
891
+ self._close_talkback(session_info)
660
892
  del self.sessions[session_id]
661
893
 
662
894
  self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"]
663
895
 
896
+ @staticmethod
897
+ def _close_talkback(session_info):
898
+ """Stop the talkback audio receiver for a session, if any."""
899
+ audio_backchannel = session_info.pop("audio_backchannel", None)
900
+ if audio_backchannel is not None:
901
+ audio_backchannel.stop()
902
+
903
+ def _talkback_payload_received(self, session_id, payload: bytes) -> None:
904
+ """Dispatch a received talkback RTP payload to the accessory implementation.
905
+
906
+ Called from the :class:`~pyhap.camera_talkback.TalkbackReceiver`
907
+ background thread, so this hops back onto the accessory driver's event
908
+ loop before calling the (synchronous) public callback, to keep
909
+ :meth:`talkback_audio_received` easy to implement without every caller
910
+ having to worry about thread-safety.
911
+ """
912
+ loop = getattr(self.driver, "loop", None)
913
+ if loop is None:
914
+ logger.warning(
915
+ "Dropping talkback audio packet: driver event loop is not available."
916
+ )
917
+ return
918
+ loop.call_soon_threadsafe(self.talkback_audio_received, session_id, payload)
919
+
920
+ def talkback_audio_received(
921
+ self, session_id, payload: bytes
922
+ ) -> None: # pylint: disable=unused-argument
923
+ """Handle received two-way ("talkback") audio for a streaming session.
924
+
925
+ Only called for sessions of accessories constructed with the
926
+ ``talkback`` option enabled (see ``__init__``). Override to consume the
927
+ received audio, e.g. decode it (the codec is whichever was negotiated,
928
+ see ``a_codec`` in the stream configuration passed to
929
+ :meth:`start_stream`) and play it out through a speaker.
930
+
931
+ :param session_id: The session ID this audio belongs to.
932
+ :type session_id: :class:`~uuid.UUID`
933
+ :param payload: The RTP payload of one received packet, still encoded
934
+ with the negotiated audio codec (not decoded by pyhap).
935
+ :type payload: ``bytes``
936
+ """
937
+
664
938
  def set_selected_stream_configuration(self, value):
665
939
  """Set the selected stream configuration.
666
940
 
@@ -787,6 +1061,32 @@ class Camera(Accessory):
787
1061
  video_ssrc = int.from_bytes(os.urandom(3), byteorder="big")
788
1062
  audio_ssrc = int.from_bytes(os.urandom(3), byteorder="big")
789
1063
 
1064
+ # If talkback is enabled, open a local receiver for the audio the
1065
+ # controller will send back during the session, and report *its* real
1066
+ # port below instead of echoing the controller's own port back at it.
1067
+ # Per the HAP spec (section 11, "IP Cameras"), the "Accessory Address"
1068
+ # TLV in this response carries the accessory's own address/ports (the
1069
+ # "Controller Address" received above carries the controller's), so
1070
+ # echoing the controller's port here left HAP clients with nowhere
1071
+ # valid to send the talkback audio to.
1072
+ #
1073
+ # Stop any previous receiver for this session first, so a
1074
+ # re-sent SetupEndpoints for the same session ID doesn't leak its
1075
+ # receiver thread and UDP socket.
1076
+ self._close_talkback(self.sessions.get(session_id, {}))
1077
+ audio_backchannel = None
1078
+ audio_rtp_port = target_audio_port
1079
+ if self.support_talkback:
1080
+ audio_backchannel = TalkbackReceiver(
1081
+ srtp_key_and_salt=audio_master_key + audio_master_salt,
1082
+ on_payload=functools.partial(
1083
+ self._talkback_payload_received, session_id
1084
+ ),
1085
+ is_ipv6=bool(is_ipv6),
1086
+ )
1087
+ audio_backchannel.start()
1088
+ audio_rtp_port = audio_backchannel.local_port
1089
+
790
1090
  res_address_tlv = tlv.encode(
791
1091
  SETUP_ADDR_INFO["ADDRESS_VER"],
792
1092
  self.stream_address_isv6,
@@ -795,7 +1095,7 @@ class Camera(Accessory):
795
1095
  SETUP_ADDR_INFO["VIDEO_RTP_PORT"],
796
1096
  struct.pack("<H", target_video_port),
797
1097
  SETUP_ADDR_INFO["AUDIO_RTP_PORT"],
798
- struct.pack("<H", target_audio_port),
1098
+ struct.pack("<H", audio_rtp_port),
799
1099
  )
800
1100
 
801
1101
  response_tlv = tlv.encode(
@@ -826,17 +1126,27 @@ class Camera(Accessory):
826
1126
  "a_port": target_audio_port,
827
1127
  "a_srtp_key": to_base64_str(audio_master_key + audio_master_salt),
828
1128
  "a_ssrc": audio_ssrc,
1129
+ "audio_backchannel": audio_backchannel,
829
1130
  }
830
1131
 
831
1132
  self._management[stream_idx].get_characteristic("SetupEndpoints").set_value(
832
1133
  response_tlv
833
1134
  )
834
1135
 
1136
+ async def run(self):
1137
+ """Start the HDS listener when recording transport is available."""
1138
+ await super().run()
1139
+ if self._recording_service is not None:
1140
+ await self._ensure_hds_listener()
1141
+
835
1142
  async def stop(self):
836
- """Stop all streaming sessions."""
1143
+ """Stop all streaming sessions and the HDS listener."""
837
1144
  await asyncio.gather(
838
1145
  *(self.stop_stream(session_info) for session_info in self.sessions.values())
839
1146
  )
1147
+ if self._hds_listener is not None:
1148
+ await self._hds_listener.stop()
1149
+ self._hds_listener = None
840
1150
 
841
1151
  # ### For client extensions ###
842
1152