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.
- {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/PKG-INFO +4 -3
- {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/SOURCES.txt +8 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/requires.txt +3 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/PKG-INFO +4 -3
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/accessory_driver.py +8 -1
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/camera.py +317 -7
- hap_python-5.1.0/pyhap/camera_talkback.py +189 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/characteristic.py +51 -3
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/const.py +1 -1
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_protocol.py +5 -0
- hap_python-5.1.0/pyhap/hds.py +185 -0
- hap_python-5.1.0/pyhap/hds_protocol.py +307 -0
- hap_python-5.1.0/pyhap/hds_recording.py +263 -0
- hap_python-5.1.0/pyhap/hds_server.py +309 -0
- hap_python-5.1.0/pyhap/hksv_recording.py +364 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/characteristics.json +141 -2
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/services.json +51 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyproject.toml +4 -2
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_accessory.py +3 -5
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_accessory_driver.py +48 -3
- hap_python-5.1.0/tests/test_camera.py +307 -0
- hap_python-5.1.0/tests/test_hds_recording.py +449 -0
- hap_python-5.1.0/tests/test_hksv_recording.py +280 -0
- hap_python-5.0.0/tests/test_camera.py +0 -145
- {hap_python-5.0.0 → hap_python-5.1.0}/AUTHORS +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/dependency_links.txt +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/HAP_python.egg-info/top_level.txt +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/LICENSE +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/MANIFEST.in +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/README.md +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/__init__.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/accessory.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/encoder.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_crypto.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_event.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_handler.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hap_server.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/hsrp.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/iid_manager.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/loader.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/params.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/resources/snapshot.jpg +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/service.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/state.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/tlv.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/pyhap/util.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/setup.cfg +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_characteristic.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_encoder.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_crypto.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_handler.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_protocol.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hap_server.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_hsrp.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_iid_manager.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_loader.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_service.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_state.py +0 -0
- {hap_python-5.0.0 → hap_python-5.1.0}/tests/test_tlv.py +0 -0
- {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.
|
|
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.
|
|
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
|
[](https://badge.fury.io/py/HAP-python) [](https://github.com/ikalchev/HAP-python) [](https://codecov.io/gh/ikalchev/HAP-python) [](https://pypi.python.org/pypi/HAP-python/) [](http://hap-python.readthedocs.io/en/latest/?badge=latest) [](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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: HAP-python
|
|
3
|
-
Version: 5.
|
|
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.
|
|
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
|
[](https://badge.fury.io/py/HAP-python) [](https://github.com/ikalchev/HAP-python) [](https://codecov.io/gh/ikalchev/HAP-python) [](https://pypi.python.org/pypi/HAP-python/) [](http://hap-python.readthedocs.io/en/latest/?badge=latest) [](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 =
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|