From 2e12ebcf9a98e5d2c5ae74b10ae0b45d786d396d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:48:29 +0100 Subject: [PATCH 01/56] Add HKSV 17.99 camera services and characteristics to the catalog Add the eight services and 27 characteristics of Apple's HomeKit Secure Video Open Source Compatibility Guide (Developer Preview 1.0) to the loader resources. Two of them, Contributing Sensors and Motion Enabled, also become optional characteristics of the Motion Sensor service. Timed write is advertised where the guide requires it. Writes to Motion Enabled and Streaming Enabled are marked admin only with an "AdminOnlyAccess": ["write"] catalog property. It is not part of the HAP representation and not enforced yet. Camera Recording Management keeps the characteristics classic recording relies on. --- pyhap/resources/characteristics.json | 235 +++++++++++++++++++++++++++ pyhap/resources/services.json | 94 ++++++++++- tests/test_loader.py | 166 ++++++++++++++++++- 3 files changed, 493 insertions(+), 2 deletions(-) diff --git a/pyhap/resources/characteristics.json b/pyhap/resources/characteristics.json index 596e5898..f185818d 100644 --- a/pyhap/resources/characteristics.json +++ b/pyhap/resources/characteristics.json @@ -133,6 +133,88 @@ "minValue": 0, "unit": "percentage" }, + "BufferActivityCommand": { + "Format": "tlv8", + "Permissions": [ + "pw" + ], + "UUID": "00008017-0000-1000-8000-0026BB765291" + }, + "BufferEventCommand": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008014-0000-1000-8000-0026BB765291" + }, + "BufferEventSequenceNumber": { + "Format": "uint32", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008015-0000-1000-8000-0026BB765291" + }, + "BufferUploadCommand": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008013-0000-1000-8000-0026BB765291" + }, + "CameraCapabilities": { + "Format": "tlv8", + "Permissions": [ + "pr" + ], + "UUID": "00008011-0000-1000-8000-0026BB765291" + }, + "CameraClientCSR": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008081-0000-1000-8000-0026BB765291" + }, + "CameraClientCertificate": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "tw" + ], + "UUID": "00008082-0000-1000-8000-0026BB765291" + }, + "CameraClientCertificateStatus": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008083-0000-1000-8000-0026BB765291" + }, + "CameraKey": { + "Format": "tlv8", + "Permissions": [ + "pw", + "tw" + ], + "UUID": "00008051-0000-1000-8000-0026BB765291" + }, + "CameraKeyID": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008052-0000-1000-8000-0026BB765291" + }, "CameraOperatingModeIndicator": { "Format": "bool", "Permissions": [ @@ -142,6 +224,22 @@ ], "UUID": "0000021D-0000-1000-8000-0026BB765291" }, + "CameraRecordingPublishingPoint": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw" + ], + "UUID": "00008016-0000-1000-8000-0026BB765291" + }, + "CameraZones": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw" + ], + "UUID": "00008022-0000-1000-8000-0026BB765291" + }, "CarbonDioxideDetected": { "Format": "uint8", "Permissions": [ @@ -265,6 +363,14 @@ "ContactNotDetected": 1 } }, + "ContributingSensors": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008086-0000-1000-8000-0026BB765291" + }, "CoolingThresholdTemperature": { "Format": "float", "Permissions": [ @@ -825,6 +931,19 @@ ], "UUID": "00000022-0000-1000-8000-0026BB765291" }, + "MotionEnabled": { + "AdminOnlyAccess": [ + "write" + ], + "Format": "bool", + "Permissions": [ + "pr", + "pw", + "ev", + "tw" + ], + "UUID": "00008087-0000-1000-8000-0026BB765291" + }, "Mute": { "Format": "bool", "Permissions": [ @@ -1055,6 +1174,15 @@ "SinglePress": 0 } }, + "RTPStreamingControl": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008045-0000-1000-8000-0026BB765291" + }, "RecordingAudioActive": { "Format": "uint8", "Permissions": [ @@ -1237,6 +1365,13 @@ ], "UUID": "00000117-0000-1000-8000-0026BB765291" }, + "SensorUUID": { + "Format": "data", + "Permissions": [ + "pr" + ], + "UUID": "0000805B-0000-1000-8000-0026BB765291" + }, "SerialNumber": { "Format": "string", "MaximumLength": 64, @@ -1386,6 +1521,19 @@ "Tampered": 1 } }, + "StreamingEnabled": { + "AdminOnlyAccess": [ + "write" + ], + "Format": "bool", + "Permissions": [ + "pr", + "pw", + "ev", + "tw" + ], + "UUID": "00008041-0000-1000-8000-0026BB765291" + }, "StreamingStatus": { "Format": "tlv8", "Permissions": [ @@ -1420,6 +1568,14 @@ ], "UUID": "00000115-0000-1000-8000-0026BB765291" }, + "SupportedAudioStreamTiers": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008044-0000-1000-8000-0026BB765291" + }, "SupportedCameraRecordingConfiguration": { "Format": "tlv8", "Permissions": [ @@ -1464,6 +1620,14 @@ ], "UUID": "00000144-0000-1000-8000-0026BB765291" }, + "SupportedVideoStreamTiers": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008043-0000-1000-8000-0026BB765291" + }, "SwingMode": { "Format": "uint8", "Permissions": [ @@ -1804,6 +1968,77 @@ "minValue": 0, "unit": "percentage" }, + "WebRTCNumberOfActiveSessions": { + "Format": "uint8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008057-0000-1000-8000-0026BB765291", + "maxValue": 255, + "minValue": 0 + }, + "WebRTCProvideAnswer": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008054-0000-1000-8000-0026BB765291" + }, + "WebRTCReoffer": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008058-0000-1000-8000-0026BB765291" + }, + "WebRTCSolicitOffer": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008053-0000-1000-8000-0026BB765291" + }, + "WebRTCStreamingControl": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008056-0000-1000-8000-0026BB765291" + }, + "WebRTCSupportedAudioStreamTiers": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "0000805A-0000-1000-8000-0026BB765291" + }, + "WebRTCSupportedVideoStreamTiers": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008059-0000-1000-8000-0026BB765291" + }, + "WebRTCUpdateSession": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "0000805C-0000-1000-8000-0026BB765291" + }, "HardwareFinish": { "Format": "tlv8", "Permissions": [ diff --git a/pyhap/resources/services.json b/pyhap/resources/services.json index 98c95aa4..8b3b4e97 100644 --- a/pyhap/resources/services.json +++ b/pyhap/resources/services.json @@ -61,6 +61,80 @@ ], "UUID": "00000096-0000-1000-8000-0026BB765291" }, + "CameraBufferManagement": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "BufferUploadCommand", + "BufferActivityCommand", + "BufferEventCommand", + "BufferEventSequenceNumber", + "CameraRecordingPublishingPoint" + ], + "UUID": "00008000-0000-1000-8000-0026BB765291" + }, + "CameraCapabilities": { + "OptionalCharacteristics": [ + "ManuallyDisabled" + ], + "RequiredCharacteristics": [ + "Version", + "CameraCapabilities" + ], + "UUID": "00008010-0000-1000-8000-0026BB765291" + }, + "CameraClientCertificateManagement": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "CameraClientCSR", + "CameraClientCertificate", + "CameraClientCertificateStatus" + ], + "UUID": "00008080-0000-1000-8000-0026BB765291" + }, + "CameraGlobalOperatingMode": { + "OptionalCharacteristics": [ + "ManuallyDisabled", + "NightVision", + "ThirdPartyCameraActive" + ], + "RequiredCharacteristics": [ + "HomeKitCameraActive", + "StreamingEnabled", + "CameraOperatingModeIndicator" + ], + "UUID": "00008032-0000-1000-8000-0026BB765291" + }, + "CameraKeyManagement": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "CameraKey", + "CameraKeyID" + ], + "UUID": "00008050-0000-1000-8000-0026BB765291" + }, + "CameraMotionZones": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "Version", + "Active", + "CameraZones" + ], + "UUID": "00008021-0000-1000-8000-0026BB765291" + }, + "CameraMultiTierRTPStreamManagement": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "StreamingEnabled", + "StatusActive", + "SupportedVideoStreamTiers", + "SupportedAudioStreamTiers", + "SupportedRTPConfiguration", + "SetupEndpoints", + "RTPStreamingControl", + "SensorUUID" + ], + "UUID": "00008031-0000-1000-8000-0026BB765291" + }, "CameraOperatingMode": { "OptionalCharacteristics": [ "CameraOperatingModeIndicator", @@ -103,6 +177,22 @@ ], "UUID": "00000204-0000-1000-8000-0026BB765291" }, + "CameraWebRTCStreamManagement": { + "OptionalCharacteristics": [], + "RequiredCharacteristics": [ + "WebRTCSolicitOffer", + "WebRTCProvideAnswer", + "WebRTCStreamingControl", + "WebRTCNumberOfActiveSessions", + "WebRTCReoffer", + "WebRTCUpdateSession", + "WebRTCSupportedVideoStreamTiers", + "WebRTCSupportedAudioStreamTiers", + "StreamingEnabled", + "SensorUUID" + ], + "UUID": "00008033-0000-1000-8000-0026BB765291" + }, "CarbonDioxideSensor": { "OptionalCharacteristics": [ "StatusActive", @@ -400,7 +490,9 @@ "StatusFault", "StatusTampered", "StatusLowBattery", - "Name" + "Name", + "ContributingSensors", + "MotionEnabled" ], "RequiredCharacteristics": [ "MotionDetected" diff --git a/tests/test_loader.py b/tests/test_loader.py index afe640a1..8714f65c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,9 +1,18 @@ """Tests for pyhap.loader.""" +from unittest.mock import patch +from uuid import UUID + import pytest from pyhap import CHARACTERISTICS_FILE, SERVICES_FILE -from pyhap.characteristic import Characteristic +from pyhap.characteristic import ( + PROP_FORMAT, + PROP_MAX_VALUE, + PROP_MIN_VALUE, + PROP_PERMISSIONS, + Characteristic, +) from pyhap.loader import Loader, get_loader from pyhap.service import Service @@ -74,3 +83,158 @@ def test_get_loader() -> None: assert loader.serv_types == loader2.serv_types assert get_loader() == loader + + +# Accesses only admin controllers may use, not part of the HAP representation. +ADMIN_ONLY_ACCESS = "AdminOnlyAccess" + +# Camera services and characteristics of the HomeKit Secure Video 17.99 guide: +# name -> (short UUID, format, permissions, AdminOnlyAccess). +HKSV3_CHARS = { + "SensorUUID": ("805B", "data", {"pr"}, None), + "MotionEnabled": ("8087", "bool", {"pr", "pw", "ev", "tw"}, ["write"]), + "SupportedVideoStreamTiers": ("8043", "tlv8", {"pr", "ev"}, None), + "SupportedAudioStreamTiers": ("8044", "tlv8", {"pr", "ev"}, None), + "CameraCapabilities": ("8011", "tlv8", {"pr"}, None), + "ContributingSensors": ("8086", "tlv8", {"pr", "ev"}, None), + "CameraKey": ("8051", "tlv8", {"pw", "tw"}, None), + "CameraKeyID": ("8052", "tlv8", {"pr", "ev"}, None), + "BufferUploadCommand": ("8013", "tlv8", {"pr", "pw", "wr"}, None), + "BufferActivityCommand": ("8017", "tlv8", {"pw"}, None), + "BufferEventCommand": ("8014", "tlv8", {"pr", "pw", "wr"}, None), + "BufferEventSequenceNumber": ("8015", "uint32", {"pr", "ev"}, None), + "CameraRecordingPublishingPoint": ("8016", "tlv8", {"pr", "pw"}, None), + "CameraZones": ("8022", "tlv8", {"pr", "pw"}, None), + "StreamingEnabled": ("8041", "bool", {"pr", "pw", "ev", "tw"}, ["write"]), + "RTPStreamingControl": ("8045", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCSolicitOffer": ("8053", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCProvideAnswer": ("8054", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCStreamingControl": ("8056", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCNumberOfActiveSessions": ("8057", "uint8", {"pr", "ev"}, None), + "WebRTCReoffer": ("8058", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCUpdateSession": ("805C", "tlv8", {"pr", "pw", "wr"}, None), + "WebRTCSupportedVideoStreamTiers": ("8059", "tlv8", {"pr", "ev"}, None), + "WebRTCSupportedAudioStreamTiers": ("805A", "tlv8", {"pr", "ev"}, None), + "CameraClientCSR": ("8081", "tlv8", {"pr", "pw", "wr"}, None), + "CameraClientCertificate": ("8082", "tlv8", {"pr", "pw", "tw"}, None), + "CameraClientCertificateStatus": ("8083", "tlv8", {"pr", "ev"}, None), +} + +# name -> (short UUID, required, optional) +HKSV3_SERVICES = { + "CameraCapabilities": ( + "8010", + ["Version", "CameraCapabilities"], + ["ManuallyDisabled"], + ), + "CameraGlobalOperatingMode": ( + "8032", + ["HomeKitCameraActive", "StreamingEnabled", "CameraOperatingModeIndicator"], + ["ManuallyDisabled", "NightVision", "ThirdPartyCameraActive"], + ), + "CameraMotionZones": ("8021", ["Version", "Active", "CameraZones"], []), + "CameraBufferManagement": ( + "8000", + [ + "BufferUploadCommand", + "BufferActivityCommand", + "BufferEventCommand", + "BufferEventSequenceNumber", + "CameraRecordingPublishingPoint", + ], + [], + ), + "CameraMultiTierRTPStreamManagement": ( + "8031", + [ + "StreamingEnabled", + "StatusActive", + "SupportedVideoStreamTiers", + "SupportedAudioStreamTiers", + "SupportedRTPConfiguration", + "SetupEndpoints", + "RTPStreamingControl", + "SensorUUID", + ], + [], + ), + "CameraWebRTCStreamManagement": ( + "8033", + [ + "WebRTCSolicitOffer", + "WebRTCProvideAnswer", + "WebRTCStreamingControl", + "WebRTCNumberOfActiveSessions", + "WebRTCReoffer", + "WebRTCUpdateSession", + "WebRTCSupportedVideoStreamTiers", + "WebRTCSupportedAudioStreamTiers", + "StreamingEnabled", + "SensorUUID", + ], + [], + ), + "CameraKeyManagement": ("8050", ["CameraKey", "CameraKeyID"], []), + "CameraClientCertificateManagement": ( + "8080", + [ + "CameraClientCSR", + "CameraClientCertificate", + "CameraClientCertificateStatus", + ], + [], + ), +} + + +def _hap_uuid(short: str) -> UUID: + return UUID(f"0000{short}-0000-1000-8000-0026BB765291") + + +@pytest.mark.parametrize("name", HKSV3_CHARS) +def test_loader_hksv3_char(name: str) -> None: + """Test the HKSV 17.99 characteristics match the guide.""" + short, char_format, permissions, admin_only_access = HKSV3_CHARS[name] + char = Loader().get_char(name) + assert char.type_id == _hap_uuid(short) + assert char.properties[PROP_FORMAT] == char_format + assert set(char.properties[PROP_PERMISSIONS]) == permissions + assert char.properties.get(ADMIN_ONLY_ACCESS) == admin_only_access + + +def test_loader_hksv3_admin_only_not_advertised() -> None: + """Test the admin only marker stays out of the HAP representation.""" + char = Loader().get_char("StreamingEnabled") + with patch.object(char, "broker") as mock_broker: + mock_broker.iid_manager.get_iid.return_value = 2 + hap_repr = char.to_HAP() + assert hap_repr["perms"] == ["pr", "pw", "ev", "tw"] + assert ADMIN_ONLY_ACCESS not in hap_repr + + +def test_loader_hksv3_active_sessions_range() -> None: + """Test the WebRTC session count carries the guide's value range.""" + char = Loader().get_char("WebRTCNumberOfActiveSessions") + assert char.properties[PROP_MIN_VALUE] == 0 + assert char.properties[PROP_MAX_VALUE] == 255 + + +@pytest.mark.parametrize("name", HKSV3_SERVICES) +def test_loader_hksv3_service(name: str) -> None: + """Test the HKSV 17.99 services match the guide.""" + short, required, optional = HKSV3_SERVICES[name] + loader = Loader() + service = loader.get_service(name) + assert service.type_id == _hap_uuid(short) + assert [char.display_name for char in service.characteristics] == required + assert loader.serv_types[name]["OptionalCharacteristics"] == optional + assert set(optional) <= set(loader.char_types) + + +def test_loader_motion_sensor_hksv3_optional() -> None: + """Test the motion sensor gains the HKSV 17.99 chars only as optional.""" + loader = Loader() + optional = loader.serv_types["MotionSensor"]["OptionalCharacteristics"] + assert {"ContributingSensors", "MotionEnabled"} <= set(optional) + service = loader.get_service("MotionSensor") + assert [char.display_name for char in service.characteristics] == ["MotionDetected"] From 207041ce2e7017ff31e3a965a09578e0ae929ce7 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:48:49 +0100 Subject: [PATCH 02/56] Fix the camera operating mode characteristic permissions Third Party Camera Active is state the camera reports, so controllers read and subscribe to it but do not write it. Night Vision and Camera Operating Mode Indicator are written with timed writes. The Camera Global Operating Mode service of HKSV 17.99 uses all three. This matches the definitions in HAP-NodeJS. --- pyhap/resources/characteristics.json | 7 ++++--- tests/test_loader.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/pyhap/resources/characteristics.json b/pyhap/resources/characteristics.json index f185818d..639b04ff 100644 --- a/pyhap/resources/characteristics.json +++ b/pyhap/resources/characteristics.json @@ -220,7 +220,8 @@ "Permissions": [ "pr", "pw", - "ev" + "ev", + "tw" ], "UUID": "0000021D-0000-1000-8000-0026BB765291" }, @@ -965,7 +966,8 @@ "Permissions": [ "pr", "pw", - "ev" + "ev", + "tw" ], "UUID": "0000011B-0000-1000-8000-0026BB765291" }, @@ -1872,7 +1874,6 @@ "Format": "bool", "Permissions": [ "pr", - "pw", "ev" ], "UUID": "0000021C-0000-1000-8000-0026BB765291" diff --git a/tests/test_loader.py b/tests/test_loader.py index 8714f65c..a38f1426 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -238,3 +238,19 @@ def test_loader_motion_sensor_hksv3_optional() -> None: assert {"ContributingSensors", "MotionEnabled"} <= set(optional) service = loader.get_service("MotionSensor") assert [char.display_name for char in service.characteristics] == ["MotionDetected"] + + +@pytest.mark.parametrize( + ("name", "permissions"), + [ + ("CameraOperatingModeIndicator", {"pr", "pw", "ev", "tw"}), + ("NightVision", {"pr", "pw", "ev", "tw"}), + ("ThirdPartyCameraActive", {"pr", "ev"}), + ], +) +def test_loader_camera_operating_mode_permissions( + name: str, permissions: set[str] +) -> None: + """Test the operating mode settings shared with the HKSV 17.99 services.""" + char = Loader().get_char(name) + assert set(char.properties[PROP_PERMISSIONS]) == permissions From f83c74c0bbafd863884a1c0ff2d454b9383027a1 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:45:55 +0100 Subject: [PATCH 03/56] Add TLV8 codecs for the HKSV 17.99 camera values Add pyhap.hksv3 with the camera capabilities, contributing sensors and camera zones values of the HomeKit Secure Video Open Source Compatibility Guide. Lists use the HAP TLV8 list encoding: one TLV per item with an empty type 0 TLV between items of the same type. Values over 255 bytes are split into 255-byte fragments, and the decoder joins a TLV to the one before it only after such a full fragment, like hksv_recording and HAP-NodeJS. Enum values a controller sends that are not known yet are kept as ints instead of failing the write, and a zone without method or a polygon without identifier is read like HAP-NodeJS does. The tests check the encoder output and the decoder against hand written vectors and a small independent parser of the list encoding. --- pyhap/hksv3.py | 280 +++++++++++++++++++++++++++++++++ tests/test_hksv3.py | 371 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 651 insertions(+) create mode 100644 pyhap/hksv3.py create mode 100644 tests/test_hksv3.py diff --git a/pyhap/hksv3.py b/pyhap/hksv3.py new file mode 100644 index 00000000..a7d6a10c --- /dev/null +++ b/pyhap/hksv3.py @@ -0,0 +1,280 @@ +"""TLV8 values of the HomeKit Secure Video 17.99 camera services. + +Follows the HAP TLV8 list encoding: each item of a list is a TLV of its own, +and items of the same type are separated by an empty TLV of type 0. Values +longer than 255 bytes are sent as 255-byte fragments of the same type; a TLV +continues the value before it only after such a full fragment. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from enum import IntEnum +from typing import TypeVar +from uuid import UUID + +SEPARATOR = b"\x00\x00" + +# Version characteristic of the Camera Capabilities and Motion Zones services. +SERVICE_VERSION = "17.99" +CAPABILITIES_VERSION = 1 +ZONES_VERSION = 2 + +_E = TypeVar("_E", bound=IntEnum) + + +class SensorType(IntEnum): + UNKNOWN = 0 + PRIMARY = 1 + GENERIC = 255 + + +class SensorIntent(IntEnum): + UNKNOWN = 0 + MAIN = 1 + PACKAGE = 2 + GENERIC = 255 + + +class VideoQuality(IntEnum): + HIGHEST = 1 + HIGH = 2 + MEDIUM = 3 + LOW = 4 + + +class ZoneMethod(IntEnum): + NORMAL = 1 + INVERTED = 2 + + +def encode_item(tag: int, value: bytes) -> bytes: + """Encode one value, fragmented into TLVs of at most 255 bytes.""" + if not value: + return bytes((tag, 0)) + return b"".join( + bytes((tag, len(chunk))) + chunk + for chunk in (value[i : i + 255] for i in range(0, len(value), 255)) + ) + + +def encode_list(tag: int, values: Iterable[bytes]) -> bytes: + """Encode the values as list items of one type.""" + return SEPARATOR.join(encode_item(tag, value) for value in values) + + +def decode_items(data: bytes) -> list[tuple[int, bytes]]: + """Split TLV8 data into its items, joining fragments and dropping separators. + + Raises ``ValueError`` for truncated data. + """ + items: list[tuple[int, bytearray]] = [] + # Type of the full 255-byte fragment right before, which a TLV continues. + current: int | None = None + offset = 0 + while offset < len(data): + if offset + 2 > len(data): + raise ValueError(f"Truncated TLV header at offset {offset}") + tag, length = data[offset], data[offset + 1] + end = offset + 2 + length + if end > len(data): + raise ValueError(f"Truncated TLV value at offset {offset}") + value = data[offset + 2 : end] + offset = end + if tag == current: + items[-1][1].extend(value) + elif tag or length: + items.append((tag, bytearray(value))) + current = tag if length == 255 else None + return [(tag, bytes(value)) for tag, value in items] + + +def _fields(data: bytes) -> dict[int, list[bytes]]: + fields: dict[int, list[bytes]] = {} + for tag, value in decode_items(data): + fields.setdefault(tag, []).append(value) + return fields + + +def _required(fields: dict[int, list[bytes]], tag: int) -> bytes: + if tag not in fields: + raise ValueError(f"Missing TLV type {tag}") + return fields[tag][0] + + +def _uint(value: int, size: int) -> bytes: + return value.to_bytes(size, "little") + + +def _to_int(data: bytes) -> int: + if not 0 < len(data) <= 8: + raise ValueError(f"Invalid integer length {len(data)}") + return int.from_bytes(data, "little") + + +def _to_enum(enum_type: type[_E], data: bytes) -> _E | int: + # Values a newer controller may send are kept as plain ints. + value = _to_int(data) + try: + return enum_type(value) + except ValueError: + return value + + +def _to_uuid(data: bytes) -> UUID: + if len(data) != 16: + raise ValueError(f"Invalid UUID length {len(data)}") + return UUID(bytes=data) + + +@dataclass +class VideoStreamCapability: + """A video encoding the camera can produce from a sensor, bit rates in kbps.""" + + identifier: UUID + quality: VideoQuality | int + width: int + height: int + frame_rate: int + average_bitrate: int + peak_bitrate: int + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, self.identifier.bytes), + encode_item(2, _uint(self.quality, 1)), + encode_item(3, _uint(self.width, 2)), + encode_item(4, _uint(self.height, 2)), + encode_item(5, _uint(self.frame_rate, 1)), + encode_item(6, _uint(self.average_bitrate, 4)), + encode_item(7, _uint(self.peak_bitrate, 4)), + ) + ) + + +@dataclass +class SensorConfiguration: + """An image sensor of the camera and its video encodings.""" + + sensor_uuid: UUID + width: int + height: int + sensor_type: SensorType | int = SensorType.PRIMARY + intent: SensorIntent | int = SensorIntent.MAIN + video_streams: list[VideoStreamCapability] = field(default_factory=list) + + def encode(self) -> bytes: + dimensions = encode_item(1, _uint(self.width, 2)) + encode_item( + 2, _uint(self.height, 2) + ) + return b"".join( + ( + encode_item(1, dimensions), + encode_item(2, self.sensor_uuid.bytes), + encode_item(3, _uint(self.sensor_type, 1)), + encode_item(4, _uint(self.intent, 1)), + encode_list(5, (stream.encode() for stream in self.video_streams)), + ) + ) + + +@dataclass +class CameraCapabilities: + """Value of the Camera Capabilities characteristic.""" + + sensors: list[SensorConfiguration] + version: int = CAPABILITIES_VERSION + + def encode(self) -> bytes: + sensors = encode_list(1, (sensor.encode() for sensor in self.sensors)) + return encode_item(1, _uint(self.version, 1)) + encode_item(2, sensors) + + +def encode_contributing_sensors(sensor_uuids: Iterable[UUID]) -> bytes: + """Encode the value of the Contributing Sensors characteristic.""" + return encode_list( + 1, (encode_item(1, sensor_uuid.bytes) for sensor_uuid in sensor_uuids) + ) + + +@dataclass +class ZonePolygon: + """A polygon in sensor pixels, the origin is the top left corner. + + A controller may leave out the identifier, it is then None. + """ + + identifier: UUID | None + vertices: list[tuple[int, int]] + + def encode(self) -> bytes: + vertices = b"".join(_uint(x, 2) + _uint(y, 2) for x, y in self.vertices) + identifier = b"" + if self.identifier is not None: + identifier = encode_item(1, self.identifier.bytes) + return identifier + encode_item(3, vertices) + + @classmethod + def decode(cls, data: bytes) -> ZonePolygon: + fields = _fields(data) + raw = fields.get(3, [b""])[0] + if len(raw) % 4: + raise ValueError(f"Invalid vertices length {len(raw)}") + vertices = [ + (_to_int(raw[i : i + 2]), _to_int(raw[i + 2 : i + 4])) + for i in range(0, len(raw), 4) + ] + identifier = _to_uuid(fields[1][0]) if 1 in fields else None + return cls(identifier, vertices) + + +@dataclass +class ZoneData: + """Polygons and how they form the region of interest. + + A missing method is :attr:`ZoneMethod.NORMAL`. + """ + + method: ZoneMethod | int + polygons: list[ZonePolygon] = field(default_factory=list) + + def encode(self) -> bytes: + return encode_item(1, _uint(self.method, 1)) + encode_list( + 3, (polygon.encode() for polygon in self.polygons) + ) + + @classmethod + def decode(cls, data: bytes) -> ZoneData: + fields = _fields(data) + method: ZoneMethod | int = ZoneMethod.NORMAL + if 1 in fields: + method = _to_enum(ZoneMethod, fields[1][0]) + return cls( + method, [ZonePolygon.decode(polygon) for polygon in fields.get(3, [])] + ) + + +@dataclass +class CameraZones: + """Value of the Camera Zones characteristic. + + Only version 2 zone data is decoded, ``zones`` is empty for others. + """ + + zones: list[ZoneData] = field(default_factory=list) + version: int = ZONES_VERSION + + def encode(self) -> bytes: + return encode_item(1, _uint(self.version, 1)) + encode_list( + 2, (zone.encode() for zone in self.zones) + ) + + @classmethod + def decode(cls, data: bytes) -> CameraZones: + fields = _fields(data) + version = _to_int(_required(fields, 1)) + if version != ZONES_VERSION: + return cls([], version) + return cls([ZoneData.decode(zone) for zone in fields.get(2, [])], version) diff --git a/tests/test_hksv3.py b/tests/test_hksv3.py new file mode 100644 index 00000000..008bf588 --- /dev/null +++ b/tests/test_hksv3.py @@ -0,0 +1,371 @@ +"""Tests for pyhap.hksv3.""" + +import random +from uuid import UUID + +import pytest + +from pyhap import hksv3 +from pyhap.hksv3 import ( + CameraCapabilities, + CameraZones, + SensorConfiguration, + SensorIntent, + SensorType, + VideoQuality, + VideoStreamCapability, + ZoneData, + ZoneMethod, + ZonePolygon, +) + +SENSOR = UUID("00112233-4455-6677-8899-aabbccddeeff") +STREAM = UUID("ffeeddcc-bbaa-9988-7766-554433221100") +ZONE = UUID("0123456789abcdef0123456789abcdef") + + +def reference_parse(data: bytes) -> list[tuple[int, bytes]]: + """Parse TLV8 the way the HAP TLV8 list encoding defines it. + + A TLV of the same type continues the value only after a full 255-byte + fragment, a zero-length type 0 TLV is a separator and no item. + """ + items: list[tuple[int, bytes]] = [] + previous: tuple[int, int] | None = None + rest = memoryview(data) + while rest: + tag, length, value = rest[0], rest[1], bytes(rest[2 : 2 + rest[1]]) + assert len(value) == length + rest = rest[2 + length :] + if previous == (tag, 255): + items[-1] = (tag, items[-1][1] + value) + elif (tag, length) != (0, 0): + items.append((tag, value)) + previous = (tag, length) + return items + + +def test_encode_item_fragments() -> None: + """Test values over 255 bytes are split into TLVs of the same type.""" + value = bytes(range(256)) * 2 + b"\x01" * 88 + encoded = hksv3.encode_item(3, value) + assert ( + encoded + == (b"\x03\xff" + value[:255] + b"\x03\xff" + value[255:510] + b"\x03\x5a") + + value[510:] + ) + assert reference_parse(encoded) == [(3, value)] + + +@pytest.mark.parametrize("length", [0, 1, 255, 510]) +def test_encode_item_boundaries(length: int) -> None: + """Test empty values and multiples of 255 bytes have no extra TLV.""" + value = b"\xaa" * length + encoded = hksv3.encode_item(7, value) + assert len(encoded) == length + 2 * max(1, -(-length // 255)) + assert reference_parse(encoded) == [(7, value)] + + +def test_encode_list() -> None: + """Test list items are separated by an empty type 0 TLV.""" + assert hksv3.encode_list(3, [b"\xaa", b"\xbb\xcc", b""]) == bytes.fromhex( + "0301aa 0000 0302bbcc 0000 0300" + ) + assert hksv3.encode_list(3, []) == b"" + + +@pytest.mark.parametrize( + ("data", "items"), + [ + ( + "010105 0302aabb 0000 0301cc", + [(1, "05"), (3, "aabb"), (3, "cc")], + ), + ("0000 0301aa 0000", [(3, "aa")]), + ("0301aa 0302bbcc 0401dd", [(3, "aa"), (3, "bbcc"), (4, "dd")]), + ("0300 0000 0300", [(3, ""), (3, "")]), + ("0301aa 0401bb 0301cc", [(3, "aa"), (4, "bb"), (3, "cc")]), + ], +) +def test_decode_items(data: str, items: list[tuple[int, str]]) -> None: + """Test decoding hand written TLV8 lists.""" + raw = bytes.fromhex(data) + expected = [(tag, bytes.fromhex(value)) for tag, value in items] + assert hksv3.decode_items(raw) == expected + assert reference_parse(raw) == expected + + +def test_decode_items_fragments() -> None: + """Test a fragmented value ends at a separator.""" + first = bytes(range(255)) + b"\x01\x02" + raw = b"\x05\xff" + first[:255] + b"\x05\x02\x01\x02\x00\x00\x05\x01\x03" + assert hksv3.decode_items(raw) == [(5, first), (5, b"\x03")] + + +@pytest.mark.parametrize("data", ["03", "0302aa", "0301aa 04"]) +def test_decode_items_truncated(data: str) -> None: + """Test truncated data raises.""" + with pytest.raises(ValueError, match="Truncated"): + hksv3.decode_items(bytes.fromhex(data)) + + +def _random_tlv8(seed: int) -> bytes: + """Build a random TLV8 sequence with separators and full fragments.""" + rng = random.Random(seed) + tlvs = [ + bytes((tag, length)) + rng.randbytes(length) + for tag, length in ( + (rng.randint(0, 4), rng.choice([0, 0, 1, 2, 254, 255, 255])) + for _ in range(rng.randint(0, 12)) + ) + ] + return b"".join(tlvs) + + +@pytest.mark.parametrize("seed", range(50)) +def test_decode_items_matches_reference(seed: int) -> None: + """Test random TLV8 sequences parse like the reference parser.""" + raw = _random_tlv8(seed) + assert hksv3.decode_items(raw) == reference_parse(raw) + + +def _capabilities() -> CameraCapabilities: + streams = [ + VideoStreamCapability(STREAM, VideoQuality.HIGH, 1920, 1080, 30, 1700, 1800), + VideoStreamCapability(STREAM, VideoQuality.LOW, 640, 360, 15, 180, 190), + ] + return CameraCapabilities( + [ + SensorConfiguration(SENSOR, 2688, 1520, video_streams=streams), + SensorConfiguration( + STREAM, 1600, 1200, SensorType.GENERIC, SensorIntent.PACKAGE + ), + ] + ) + + +def test_camera_capabilities_golden() -> None: + """Test a single sensor capabilities value byte for byte.""" + stream = VideoStreamCapability(STREAM, VideoQuality.LOW, 640, 360, 15, 180, 190) + value = CameraCapabilities( + [SensorConfiguration(SENSOR, 1920, 1080, video_streams=[stream])] + ) + stream_tlv = ( + "0110" + + STREAM.hex + + "020104" + + "03028002" + + "04026801" + + "05010f" + + "0604b4000000" + + "0704be000000" + ) + sensor_tlv = ( + "0108" + + "01028007" + + "02023804" + + "0210" + + SENSOR.hex + + "030101" + + "040101" + + "05" + + f"{len(stream_tlv) // 2:02x}" + + stream_tlv + ) + expected = "010101" + "02" + f"{len(sensor_tlv) // 2 + 2:02x}" + expected += "01" + f"{len(sensor_tlv) // 2:02x}" + sensor_tlv + assert value.encode() == bytes.fromhex(expected) + + +def test_camera_capabilities_lists() -> None: + """Test sensors and video streams are separate list items.""" + top = reference_parse(_capabilities().encode()) + assert [tag for tag, _ in top] == [1, 2] + assert top[0][1] == b"\x01" + sensors = reference_parse(top[1][1]) + assert [tag for tag, _ in sensors] == [1, 1] + + first = dict(reference_parse(sensors[0][1])) + assert reference_parse(first[1]) == [(1, b"\x80\x0a"), (2, b"\xf0\x05")] + assert first[2] == SENSOR.bytes + assert first[3] == bytes([SensorType.PRIMARY]) + assert first[4] == bytes([SensorIntent.MAIN]) + streams = [value for tag, value in reference_parse(sensors[0][1]) if tag == 5] + assert len(streams) == 2 + assert dict(reference_parse(streams[1]))[2] == bytes([VideoQuality.LOW]) + + second = reference_parse(sensors[1][1]) + assert (3, b"\xff") in second + assert (4, bytes([SensorIntent.PACKAGE])) in second + assert 5 not in dict(second) + + +def test_camera_capabilities_fragmented() -> None: + """Test a value over 255 bytes, two sensors with four encodings each.""" + qualities = list(VideoQuality) + sensors = [ + SensorConfiguration( + sensor_uuid, + 3840, + 2160, + video_streams=[ + VideoStreamCapability(UUID(int=i), quality, 640, 360, 15, 180, 190) + for i, quality in enumerate(qualities) + ], + ) + for sensor_uuid in (SENSOR, STREAM) + ] + encoded = CameraCapabilities(sensors).encode() + # Version, then the sensors list in a full and a partial type 2 fragment. + assert encoded[:5] == bytes.fromhex("010101 02ff") + assert encoded[260] == 2 + assert len(encoded) == 3 + 2 * 2 + 454 + + top = reference_parse(encoded) + assert [(tag, len(value)) for tag, value in top] == [(1, 1), (2, 454)] + items = reference_parse(top[1][1]) + assert [tag for tag, _ in items] == [1, 1] + sensor_fields = [reference_parse(item) for _, item in items] + streams = [ + [dict(reference_parse(value)) for tag, value in fields if tag == 5] + for fields in sensor_fields + ] + assert [dict(fields)[2] for fields in sensor_fields] == [ + SENSOR.bytes, + STREAM.bytes, + ] + expected = [(UUID(int=i).bytes, bytes([q])) for i, q in enumerate(qualities)] + assert [[(s[1], s[2]) for s in sensor] for sensor in streams] == [expected] * 2 + + +def test_contributing_sensors() -> None: + """Test each contributing sensor is its own list item.""" + encoded = hksv3.encode_contributing_sensors([SENSOR, STREAM]) + assert encoded == bytes.fromhex( + "0112 0110" + SENSOR.hex + "0000 0112 0110" + STREAM.hex + ) + assert hksv3.encode_contributing_sensors([]) == b"" + + +def _zones_hex(method: str = "01", vertices: str = "0000000080070000") -> str: + polygon_a = "0110" + ZONE.hex + "03" + f"{len(vertices) // 2:02x}" + vertices + polygon_b = "0110" + STREAM.hex + "030c" + "0a000a00" + "14000a00" + "0a001400" + zone = ( + "0101" + + method + + "03" + + f"{len(polygon_a) // 2:02x}" + + polygon_a + + "0000" + + "03" + + f"{len(polygon_b) // 2:02x}" + + polygon_b + ) + return "010102" + "02" + f"{len(zone) // 2:02x}" + zone + + +def test_camera_zones_decode() -> None: + """Test decoding hand written zones with two polygons.""" + raw = bytes.fromhex(_zones_hex()) + zones = CameraZones.decode(raw) + assert zones == CameraZones( + [ + ZoneData( + ZoneMethod.NORMAL, + [ + ZonePolygon(ZONE, [(0, 0), (1920, 0)]), + ZonePolygon(STREAM, [(10, 10), (20, 10), (10, 20)]), + ], + ) + ] + ) + assert zones.encode() == raw + + +def test_camera_zones_several() -> None: + """Test several zone data items survive a round trip.""" + zones = CameraZones( + [ + ZoneData(ZoneMethod.INVERTED, [ZonePolygon(ZONE, [(1, 2)])]), + ZoneData(ZoneMethod.NORMAL), + ] + ) + encoded = zones.encode() + assert [tag for tag, _ in reference_parse(encoded)] == [1, 2, 2] + assert CameraZones.decode(encoded) == zones + + +def test_camera_zones_fragmented_polygon() -> None: + """Test a polygon with more than 255 bytes of vertices.""" + vertices = [(i, 2 * i) for i in range(100)] + zones = CameraZones([ZoneData(ZoneMethod.NORMAL, [ZonePolygon(ZONE, vertices)])]) + encoded = zones.encode() + zone = reference_parse(reference_parse(encoded)[1][1]) + assert [tag for tag, _ in zone] == [1, 3] + polygon = dict(reference_parse(zone[1][1])) + assert polygon[1] == ZONE.bytes + assert polygon[3] == b"".join( + x.to_bytes(2, "little") + y.to_bytes(2, "little") for x, y in vertices + ) + assert CameraZones.decode(encoded) == zones + + +def test_camera_zones_unknown_method() -> None: + """Test an unknown zone method is kept as int.""" + zones = CameraZones.decode(bytes.fromhex(_zones_hex(method="09"))) + assert zones.zones[0].method == 9 + assert not isinstance(zones.zones[0].method, ZoneMethod) + + +def test_camera_zones_unknown_types() -> None: + """Test unknown TLV types are ignored at every level.""" + polygon = "0110" + ZONE.hex + "0304" + "01000200" + "0902aabb" + zone = "010101" + "0901cc" + "03" + f"{len(polygon) // 2:02x}" + polygon + raw = bytes.fromhex("010102" + "0a01dd" + "02" + f"{len(zone) // 2:02x}" + zone) + assert reference_parse(raw)[1] == (10, b"\xdd") + assert CameraZones.decode(raw) == CameraZones( + [ZoneData(ZoneMethod.NORMAL, [ZonePolygon(ZONE, [(1, 2)])])] + ) + + +@pytest.mark.parametrize( + ("zone", "expected"), + [ + # No method. + ( + "0306" + "0304" + "01000200", + ZoneData(ZoneMethod.NORMAL, [ZonePolygon(None, [(1, 2)])]), + ), + # No polygon identifier and no vertices. + ("010102" + "0300", ZoneData(ZoneMethod.INVERTED, [ZonePolygon(None, [])])), + ], +) +def test_camera_zones_missing_fields(zone: str, expected: ZoneData) -> None: + """Test missing fields get defaults, like HAP-NodeJS reads them.""" + raw = bytes.fromhex("010102" + "02" + f"{len(zone) // 2:02x}" + zone) + zones = CameraZones.decode(raw) + assert zones == CameraZones([expected]) + assert CameraZones.decode(zones.encode()) == zones + + +def test_camera_zones_other_version() -> None: + """Test zone data of another version is not interpreted.""" + zones = CameraZones.decode(bytes.fromhex("010103 0203aabbcc")) + assert zones == CameraZones([], 3) + + +@pytest.mark.parametrize( + "data", + [ + "", + "0100", + "010902000000000000000000", + _zones_hex(vertices="000000"), + "010102 0203 030100", + "010102 020b 010101 0306 0102aabb 0300", + ], +) +def test_camera_zones_invalid(data: str) -> None: + """Test malformed zones raise ValueError.""" + with pytest.raises(ValueError): + CameraZones.decode(bytes.fromhex(data)) From 576f6e75e8ad547f54775221896dea4424f682f8 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:29:51 +0100 Subject: [PATCH 04/56] Add HKSV 17.99 camera capabilities and motion zones A new "sensors" camera option takes the image sensors of the camera, each with its real dimensions, type, intent (main or package camera) and video encodings. With it the camera advertises them in the Camera Capabilities service, accepts motion zones through the Camera Motion Zones service and adds Contributing Sensors and Motion Enabled to the motion sensor. Cameras without the option are unchanged. set_motion_detected reports the sensors that saw the motion and reports none while the user turned motion detection off. Integrations read the zones from camera_zones and override motion_zones_changed and motion_enabled_changed to react. The hooks only hear about changes, not about a controller writing the same value again. Zones, their Active state and Motion Enabled are kept in the driver's accessory state like the classic recording state: restored when the camera is rebuilt and returned to their defaults when the last pairing is removed, calling the hooks for the values that change once all state is reset. Both kinds of state share one save per burst of writes. A zones write that does not decode fails and leaves the previous zones in place. A warning is logged for sensors with fewer than the three video encodings the guide requires and for motion reported for sensors that are not configured. --- pyhap/camera.py | 299 +++++++++++++++++++++--- tests/test_camera_capabilities.py | 377 ++++++++++++++++++++++++++++++ tests/test_hksv3_state.py | 158 +++++++++++++ 3 files changed, 807 insertions(+), 27 deletions(-) create mode 100644 tests/test_camera_capabilities.py create mode 100644 tests/test_hksv3_state.py diff --git a/pyhap/camera.py b/pyhap/camera.py index b20cac9e..e898c7b3 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -17,7 +17,7 @@ """ import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable, Iterable import functools import hashlib import ipaddress @@ -28,7 +28,15 @@ from typing import Any from uuid import UUID -from pyhap import RESOURCE_DIR, hds, hds_recording, hds_server, hksv_recording, tlv +from pyhap import ( + RESOURCE_DIR, + hds, + hds_recording, + hds_server, + hksv3, + hksv_recording, + tlv, +) from pyhap.accessory import Accessory from pyhap.camera_talkback import TalkbackReceiver, talkback_available from pyhap.characteristic import Characteristic @@ -243,6 +251,11 @@ ("CameraOperatingMode", "HomeKitCameraActive"), ("CameraOperatingMode", "PeriodicSnapshotsActive"), ) +# accessory_state key of the HKSV 17.99 settings. +HKSV3_STATE = "hksv3" +HKSV3_MOTION_CHARS = ("ContributingSensors", "MotionEnabled") +# Concurrent video encodings each sensor must offer (HKSV 17.99 guide, section 2). +HKSV3_MIN_SENSOR_STREAMS = 3 SUPPORTED_RECORDING_CHARS = ( "SupportedCameraRecordingConfiguration", "SupportedVideoRecordingConfiguration", @@ -459,6 +472,14 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: the stream. The string can contain the keywords, corresponding to the video and audio configuration that was negotiated between the camera and the client. See the ``start`` method for a full list of parameters. + - sensors - list of :class:`pyhap.hksv3.SensorConfiguration`, the image + sensors of the camera. Opts into the HomeKit Secure Video 17.99 + Camera Capabilities and Camera Motion Zones services, and adds + Contributing Sensors and Motion Enabled to the motion sensor, + or adds a motion sensor without ``recording``. Each sensor needs + at least three video encodings. With ``recording``, turning it + on for a paired camera changes the instance IDs of the recording + services with the default IID manager. :type options: ``dict`` """ @@ -494,7 +515,8 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: # Last selection that decoded, the char may hold a rejected write. self._selected_recording_raw = None self._recording_defaults = {} - self._recording_save_pending = False + self._save_pending = False + self._recording_changed = False self._motion_detected_char = None # Bumped on stop so a start still binding does not publish its listener. self._hds_generation = 0 @@ -503,11 +525,27 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: self._recording_stream = None self._homekit_camera_active_char = None self._motion_status_active_char = None + # Persisted HKSV 17.99 chars with their state key, default and the + # handler a reset calls. + self._hksv3_defaults: dict[ + Characteristic, tuple[str, Any, Callable[[Any], None]] + ] = {} + # The values the handlers last saw, they only hear about changes. + self._hksv3_seen: dict[Characteristic, Any] = {} + self._sensors: list[hksv3.SensorConfiguration] = list( + options.get("sensors") or [] + ) + self._motion_zones_service: Service | None = None + self._contributing_sensors_char: Characteristic | None = None + self._motion_enabled_char: Characteristic | None = None if options.get("video"): self._setup_stream_management(options) # Classic HomeKit Secure Video recording (HDS + fragmented MP4). if options.get("recording"): self._setup_recording_management(options) + if self._sensors: + self._setup_camera_capabilities() + self._restore_hksv3_state() @property def streaming_status(self) -> bytes | None: @@ -624,7 +662,10 @@ def _setup_recording_management(self, options: dict[str, Any]) -> None: # HKSV records on an event trigger; the camera owns a motion sensor and # links it (and the data stream transport) to the recording management. - motion = self.add_preload_service("MotionSensor", chars=["StatusActive"]) + motion = self.add_preload_service( + "MotionSensor", + chars=["StatusActive", *(HKSV3_MOTION_CHARS if self._sensors else ())], + ) self._motion_detected_char = motion.configure_char( "MotionDetected", value=False ) @@ -668,6 +709,114 @@ def _setup_recording_management(self, options: dict[str, Any]) -> None: self._restore_recording_state() self._setup_data_stream_transport() + def _setup_camera_capabilities(self) -> None: + """Advertise the image sensors and accept motion zones (HKSV 17.99).""" + for sensor in self._sensors: + if len(sensor.video_streams) < HKSV3_MIN_SENSOR_STREAMS: + logger.warning( + "%s: sensor %s has %d video encodings, HKSV needs at least %d", + self.display_name, + sensor.sensor_uuid, + len(sensor.video_streams), + HKSV3_MIN_SENSOR_STREAMS, + ) + capabilities = self.add_preload_service("CameraCapabilities") + capabilities.configure_char("Version", value=hksv3.SERVICE_VERSION) + capabilities.configure_char( + "CameraCapabilities", + value=to_base64_str(hksv3.CameraCapabilities(self._sensors).encode()), + ) + zones = self.add_preload_service("CameraMotionZones") + zones.configure_char("Version", value=hksv3.SERVICE_VERSION) + self._motion_zones_service = zones + + motion = self.get_service("MotionSensor") + if motion is None: + motion = self.add_preload_service("MotionSensor", chars=HKSV3_MOTION_CHARS) + self._motion_detected_char = motion.get_characteristic("MotionDetected") + self._contributing_sensors_char = motion.get_characteristic( + "ContributingSensors" + ) + self._motion_enabled_char = motion.configure_char("MotionEnabled", value=True) + for char, key, handler in ( + ( + zones.get_characteristic("Active"), + "motion_zones_active", + self._handle_motion_zones, + ), + ( + zones.get_characteristic("CameraZones"), + "camera_zones", + self._handle_motion_zones, + ), + (self._motion_enabled_char, "motion_enabled", self._handle_motion_enabled), + ): + self._persist_hksv3_char(char, key, handler) + # Check the zones before they count as written. + zones.get_characteristic("CameraZones").setter_callback = self._set_camera_zones + + @property + def camera_zones(self) -> hksv3.CameraZones | None: + """The motion zones the controller wrote, if any.""" + if self._motion_zones_service is None: + return None + value = self._motion_zones_service.get_characteristic("CameraZones").value + if not value: + return None + try: + return hksv3.CameraZones.decode(base64_to_bytes(value)) + # A damaged state file. + except ValueError: + return None + + @property + def motion_zones_active(self) -> bool: + """Whether only motion inside :attr:`camera_zones` should count.""" + if self._motion_zones_service is None: + return False + return bool(self._motion_zones_service.get_characteristic("Active").value) + + @property + def motion_enabled(self) -> bool: + """Whether motion detection is on, cameras without the setting always are.""" + return self._motion_enabled_char is None or bool( + self._motion_enabled_char.value + ) + + def _set_camera_zones(self, value: str) -> None: + assert self._motion_zones_service is not None # noqa: S101 + char = self._motion_zones_service.get_characteristic("CameraZones") + try: + if value: + hksv3.CameraZones.decode(base64_to_bytes(value)) + except ValueError: + # Fail the write and keep the zones the camera had. + char.value = self._hksv3_seen[char] + raise + self._hksv3_written(char, value) + + def _handle_motion_zones(self, value: Any) -> None: + self.motion_zones_changed() + + def motion_zones_changed(self) -> None: + """React to the controller changing :attr:`camera_zones` or turning + them on or off. Override. + + Only changes call it, not a controller writing the same value again. + """ + + def _handle_motion_enabled(self, value: Any) -> None: + if not value: + self.set_motion_detected(False) + self.motion_enabled_changed(bool(value)) + + def motion_enabled_changed(self, enabled: bool) -> None: + """React to the user turning motion detection on or off. Override. + + While it is off, :meth:`set_motion_detected` reports no motion. Only + changes call it, not a controller writing the same value again. + """ + def _setup_data_stream_transport(self) -> None: """Create the Data Stream Transport Management service used by HDS. @@ -828,10 +977,24 @@ def publish( sender_client_addr: Any = None, immediate: bool = False, ) -> None: - """Forward the update and store recording state changes.""" + """Forward the update and store recording and HKSV 17.99 state changes.""" super().publish(value, sender, sender_client_addr, immediate) + if sender in self._hksv3_defaults: + # Local changes too, so a later write of the same value is no change. + self._hksv3_seen[sender] = value # Without an aid there is no entry to store under. - if sender not in self._recording_defaults or self.aid is None: + if self.aid is None: + return + if sender in self._hksv3_defaults: + key = self._hksv3_defaults[sender][0] + state = self.driver.accessory_state(self.aid) + kept = state.get(HKSV3_STATE) + # A damaged state file must not fail the write. + if not isinstance(kept, dict): + kept = state[HKSV3_STATE] = {} + kept[key] = value + self._schedule_save("camera") + if sender not in self._recording_defaults: return # Store now so a camera rebuilt before the save restores this change. stored = {char.display_name: char.value for char in self._recording_defaults} @@ -841,15 +1004,20 @@ def publish( self._schedule_recording_save() def _schedule_recording_save(self) -> None: - if self._recording_save_pending: + self._recording_changed = True + self._schedule_save("recording") + + def _schedule_save(self, what: str) -> None: + if self._save_pending: return # One disk write per hub burst. - self._recording_save_pending = True + self._save_pending = True try: - self.driver.add_job(self._save_recording_state) + self.driver.add_job(self._save_state) except RuntimeError: - self._recording_save_pending = False - logger.warning("Could not schedule saving the recording state") + self._save_pending = False + self._recording_changed = False + logger.warning("Could not schedule saving the %s state", what) def _supported_recording_hash(self) -> str: digest = hashlib.sha256() @@ -860,14 +1028,53 @@ def _supported_recording_hash(self) -> str: return digest.hexdigest() @callback - def _save_recording_state(self) -> None: - self._recording_save_pending = False + def _save_state(self) -> None: + self._save_pending = False + recording_changed, self._recording_changed = self._recording_changed, False self.driver.async_persist() - self.recording_state_changed() + if recording_changed: + self.recording_state_changed() + + def _persist_hksv3_char( + self, char: Characteristic, key: str, handler: Callable[[Any], None] + ) -> None: + """Keep the value of ``char`` across restarts, reset it for a new home. + + A controller write or a reset that changes the value calls ``handler`` + with the new value, a reset once per handler. The constructor restores + the stored values afterwards. + """ + self._hksv3_defaults[char] = (key, char.value, handler) + char.setter_callback = functools.partial(self._hksv3_written, char) + + def _hksv3_written(self, char: Characteristic, value: Any) -> None: + """Call the handler of ``char`` if the write changed its value.""" + if self._hksv3_seen.get(char) == value: + return + self._hksv3_seen[char] = value + self._hksv3_defaults[char][2](value) + + def _restore_hksv3_state(self) -> None: + if self.aid is not None: + self._load_hksv3_state(self.aid) + self._hksv3_seen = {char: char.value for char in self._hksv3_defaults} + + def _load_hksv3_state(self, aid: int) -> None: + stored = self.driver.accessory_state(aid).get(HKSV3_STATE) + if not isinstance(stored, dict): + return + for char, (key, _, _) in self._hksv3_defaults.items(): + if key not in stored: + continue + try: + char.set_value(stored[key], should_notify=False) + except (TypeError, ValueError, OverflowError): + logger.warning("Ignoring stored %s of aid %s", key, aid) def restore_accessory_state(self) -> None: - """Restore the stored recording state.""" + """Restore the stored recording and HKSV 17.99 state.""" self._restore_recording_state() + self._restore_hksv3_state() def _restore_recording_state(self) -> None: # Private so construction does not run a subclass override early. @@ -908,26 +1115,64 @@ def _restore_recording_state(self) -> None: self._selected_recording_raw = selected def reset_accessory_state(self) -> None: - """Return the recording state to its defaults for a new home.""" - if not self._recording_defaults: - return - for char, value in self._recording_defaults.items(): - char.set_value(value, should_notify=False) - self._selected_recording_config = None - self._selected_recording_raw = None - if self.aid is not None: - self.driver.accessory_state(self.aid).pop("recording", None) - self._schedule_recording_save() + """Return the recording and HKSV 17.99 state to its defaults. - def set_motion_detected(self, detected: bool) -> None: + Once all state is reset, the handlers of the HKSV 17.99 values that + changed are called. + """ + handlers: list[tuple[Callable[[Any], None], Any]] = [] + for char, (_, value, handler) in self._hksv3_defaults.items(): + changed = char.value != value + char.set_value(value, should_notify=False) + self._hksv3_seen[char] = value + if changed and all(handler != known for known, _ in handlers): + handlers.append((handler, value)) + if self._hksv3_defaults and self.aid is not None: + self.driver.accessory_state(self.aid).pop(HKSV3_STATE, None) + self._schedule_save("camera") + if self._recording_defaults: + for char, value in self._recording_defaults.items(): + char.set_value(value, should_notify=False) + self._selected_recording_config = None + self._selected_recording_raw = None + if self.aid is not None: + self.driver.accessory_state(self.aid).pop("recording", None) + self._schedule_recording_save() + for handler, value in handlers: + handler(value) + + def set_motion_detected( + self, detected: bool, sensors: Iterable[UUID] | None = None + ) -> None: """Fire (``True``) or clear (``False``) the recording motion trigger. This is the entry point an integration calls when its own motion detector changes state: it drives the HKSV event-trigger MotionSensor, which is what makes the Home Hub start (and later finish) a recording. + + With ``sensors`` the camera names the sensors that saw the motion, + by default or when empty the first one. Ignored without the + ``sensors`` option. """ + detected = bool(detected) and self.motion_enabled + if self._contributing_sensors_char is not None: + if not detected: + sensors = () + elif not (sensors := tuple(sensors or ())): + sensors = (self._sensors[0].sensor_uuid,) + else: + configured = {sensor.sensor_uuid for sensor in self._sensors} + if unknown := [uuid for uuid in sensors if uuid not in configured]: + logger.warning( + "%s: motion reported for sensors that are not configured: %s", + self.display_name, + ", ".join(map(str, unknown)), + ) + self._contributing_sensors_char.set_value( + to_base64_str(hksv3.encode_contributing_sensors(sensors)) + ) if self._motion_detected_char is not None: - self._motion_detected_char.set_value(bool(detected)) + self._motion_detected_char.set_value(detected) def _recording_open_gate( self, manager: hds_recording.RecordingStreamManager diff --git a/tests/test_camera_capabilities.py b/tests/test_camera_capabilities.py new file mode 100644 index 00000000..ad7436fe --- /dev/null +++ b/tests/test_camera_capabilities.py @@ -0,0 +1,377 @@ +"""Tests for the HKSV 17.99 camera capabilities of pyhap.camera.""" + +import asyncio +from collections.abc import AsyncIterator +import logging +import pathlib +from typing import Any +from unittest.mock import MagicMock, patch +from uuid import UUID + +import pytest + +from pyhap import hksv3 +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import HKSV3_STATE, Camera +from pyhap.const import ( + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_STATUS, + HAP_REPR_VALUE, + HAP_SERVER_STATUS, +) +from pyhap.util import base64_to_bytes, to_base64_str + +CAMERA_AID = 2 +MAIN = UUID("00112233-4455-6677-8899-aabbccddeeff") +PACKAGE = UUID("ffeeddcc-bbaa-9988-7766-554433221100") +SENSORS = [ + hksv3.SensorConfiguration( + MAIN, + 2688, + 1520, + video_streams=[ + hksv3.VideoStreamCapability( + PACKAGE, hksv3.VideoQuality.HIGH, 1920, 1080, 30, 1700, 1800 + ) + ], + ), + hksv3.SensorConfiguration( + PACKAGE, 1600, 1200, hksv3.SensorType.GENERIC, hksv3.SensorIntent.PACKAGE + ), +] +ZONES = hksv3.CameraZones( + [ + hksv3.ZoneData( + hksv3.ZoneMethod.NORMAL, + [hksv3.ZonePolygon(MAIN, [(0, 0), (1344, 0), (1344, 760)])], + ) + ] +) + + +@pytest.fixture(name="driver") +async def driver_fixture(tmp_path: pathlib.Path) -> AsyncIterator[AccessoryDriver]: + with patch("pyhap.accessory_driver.AccessoryDriver.async_persist"): + yield AccessoryDriver( + loop=asyncio.get_running_loop(), + listen_address="127.0.0.1", + persist_file=str(tmp_path / "accessory.state"), + ) + + +def _camera(driver: Any, **options: Any) -> Camera: + return Camera({"address": "127.0.0.1", **options}, driver, "Cam", aid=CAMERA_AID) + + +def _char(camera: Camera, service: str, name: str) -> Any: + found = camera.get_service(service) + assert found is not None + return found.get_characteristic(name) + + +def _chars(camera: Camera, service: str) -> list[str | None] | None: + found = camera.get_service(service) + if found is None: + return None + return [char.display_name for char in found.characteristics] + + +@pytest.mark.parametrize( + ("recording", "motion_chars"), + [(False, None), (True, ["MotionDetected", "StatusActive"])], +) +async def test_without_sensors( + driver: Any, recording: bool, motion_chars: list[str] | None +) -> None: + """Test cameras without the sensors option are unchanged.""" + camera = _camera(driver, recording=recording) + assert camera.get_service("CameraCapabilities") is None + assert camera.get_service("CameraMotionZones") is None + assert _chars(camera, "MotionSensor") == motion_chars + assert camera.camera_zones is None + assert camera.motion_zones_active is False + assert camera.motion_enabled is True + camera.set_motion_detected(True, [MAIN]) + + +async def test_sensor_minimum_streams( + driver: Any, caplog: pytest.LogCaptureFixture +) -> None: + """Test sensors with fewer than three encodings log a warning.""" + stream = SENSORS[0].video_streams[0] + sensors = [ + hksv3.SensorConfiguration(MAIN, 1920, 1080, video_streams=[stream] * 3), + *SENSORS[1:], + ] + with caplog.at_level(logging.WARNING): + _camera(driver, sensors=sensors) + assert caplog.messages == [ + f"Cam: sensor {PACKAGE} has 0 video encodings, HKSV needs at least 3" + ] + + +async def test_services(driver: Any) -> None: + """Test the capabilities and motion zones services.""" + camera = _camera(driver, sensors=SENSORS) + assert _char(camera, "CameraCapabilities", "Version").value == "17.99" + capabilities = _char(camera, "CameraCapabilities", "CameraCapabilities") + assert base64_to_bytes(capabilities.value) == ( + hksv3.CameraCapabilities(SENSORS).encode() + ) + assert _char(camera, "CameraMotionZones", "Version").value == "17.99" + assert _chars(camera, "MotionSensor") == [ + "MotionDetected", + "ContributingSensors", + "MotionEnabled", + ] + assert _char(camera, "MotionSensor", "MotionEnabled").value is True + + +async def test_recording_motion_sensor(driver: Any) -> None: + """Test recording and capabilities share one motion sensor.""" + camera = _camera(driver, recording=True, sensors=SENSORS) + motion = [s for s in camera.services if s.display_name == "MotionSensor"] + assert len(motion) == 1 + assert _chars(camera, "MotionSensor") == [ + "MotionDetected", + "StatusActive", + "ContributingSensors", + "MotionEnabled", + ] + + +def _contributing(camera: Camera) -> bytes: + return base64_to_bytes(_char(camera, "MotionSensor", "ContributingSensors").value) + + +@pytest.mark.parametrize("default", [None, []]) +async def test_contributing_sensors(driver: Any, default: list[UUID] | None) -> None: + """Test motion names the sensors that saw it.""" + camera = _camera(driver, sensors=SENSORS) + detected = _char(camera, "MotionSensor", "MotionDetected") + + camera.set_motion_detected(True, default) + assert detected.value is True + assert _contributing(camera) == hksv3.encode_contributing_sensors([MAIN]) + + camera.set_motion_detected(True, [MAIN, PACKAGE]) + assert _contributing(camera) == hksv3.encode_contributing_sensors([MAIN, PACKAGE]) + + camera.set_motion_detected(False, [PACKAGE]) + assert detected.value is False + assert _contributing(camera) == b"" + + +async def test_contributing_sensors_unknown( + driver: Any, caplog: pytest.LogCaptureFixture +) -> None: + """Test motion from a sensor that is not configured logs a warning.""" + camera = _camera(driver, sensors=SENSORS) + other = UUID(int=1) + caplog.clear() + with caplog.at_level(logging.WARNING): + camera.set_motion_detected(True, iter([MAIN, other])) + assert caplog.messages == [ + f"Cam: motion reported for sensors that are not configured: {other}" + ] + assert _contributing(camera) == hksv3.encode_contributing_sensors([MAIN, other]) + + +async def test_motion_enabled(driver: Any) -> None: + """Test turning motion detection off clears and suppresses motion.""" + camera = _camera(driver, sensors=SENSORS) + camera.motion_enabled_changed = MagicMock() + detected = _char(camera, "MotionSensor", "MotionDetected") + enabled = _char(camera, "MotionSensor", "MotionEnabled") + camera.set_motion_detected(True) + + enabled.client_update_value(False) + camera.motion_enabled_changed.assert_called_once_with(False) + assert camera.motion_enabled is False + assert detected.value is False + camera.set_motion_detected(True) + assert detected.value is False + assert _contributing(camera) == b"" + + enabled.client_update_value(True) + camera.motion_enabled_changed.assert_called_with(True) + camera.set_motion_detected(True) + assert detected.value is True + + +async def test_motion_zones(driver: Any) -> None: + """Test the controller writing and clearing motion zones.""" + camera = _camera(driver, sensors=SENSORS) + camera.motion_zones_changed = MagicMock() + zones = _char(camera, "CameraMotionZones", "CameraZones") + + _char(camera, "CameraMotionZones", "Active").client_update_value(1) + assert camera.motion_zones_active is True + zones.client_update_value(to_base64_str(ZONES.encode())) + assert camera.camera_zones == ZONES + assert camera.motion_zones_changed.call_count == 2 + + zones.client_update_value("") + assert camera.camera_zones is None + assert camera.motion_zones_changed.call_count == 3 + + +@pytest.mark.parametrize("value", ["AQ==", "!!"]) +@pytest.mark.parametrize( + ("previous", "kept"), [(None, ""), (ZONES, to_base64_str(ZONES.encode()))] +) +async def test_motion_zones_malformed( + driver: Any, value: str, previous: hksv3.CameraZones | None, kept: str +) -> None: + """Test a malformed zones write fails and keeps the previous zones.""" + camera = _camera(driver, sensors=SENSORS) + _char(camera, "CameraMotionZones", "Active").client_update_value(1) + zones = _char(camera, "CameraMotionZones", "CameraZones") + zones.client_update_value(kept) + camera.motion_zones_changed = MagicMock() + with pytest.raises(ValueError): + zones.client_update_value(value) + camera.motion_zones_changed.assert_not_called() + assert zones.value == kept + assert camera.camera_zones == previous + assert camera.motion_zones_active is True + + +async def test_motion_zones_damaged_state(driver: Any) -> None: + """Test stored zones that do not decode count as no zones.""" + driver.accessory_state(CAMERA_AID)[HKSV3_STATE] = {"camera_zones": "AQ=="} + camera = _camera(driver, sensors=SENSORS) + assert _char(camera, "CameraMotionZones", "CameraZones").value == "AQ==" + assert camera.camera_zones is None + + +async def test_motion_zones_malformed_write(driver: Any) -> None: + """Test a controller reading the zones after a failed write gets the old ones.""" + camera = Camera({"address": "127.0.0.1", "sensors": SENSORS}, driver, "Cam") + driver.add_accessory(camera) + zones = _char(camera, "CameraMotionZones", "CameraZones") + iid = camera.iid_manager.get_iid(zones) + kept = to_base64_str(ZONES.encode()) + zones.client_update_value(kept) + + response = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: 1, HAP_REPR_IID: iid, HAP_REPR_VALUE: "AQ=="} + ] + }, + ("127.0.0.1", 5000), + ) + assert response is not None + assert response[HAP_REPR_CHARS][0][HAP_REPR_STATUS] == ( + HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE + ) + read = driver.get_characteristics([f"1.{iid}"]) + assert read[HAP_REPR_CHARS][0][HAP_REPR_VALUE] == kept + + +async def test_settings_survive_restart(driver: Any) -> None: + """Test zones and motion settings are restored.""" + camera = _camera(driver, sensors=SENSORS) + _char(camera, "CameraMotionZones", "Active").client_update_value(1) + _char(camera, "CameraMotionZones", "CameraZones").client_update_value( + to_base64_str(ZONES.encode()) + ) + _char(camera, "MotionSensor", "MotionEnabled").client_update_value(False) + + camera = _camera(driver, sensors=SENSORS) + assert camera.motion_zones_active is True + assert camera.camera_zones == ZONES + assert camera.motion_enabled is False + + +async def test_reset_calls_hooks(driver: Any) -> None: + """Test removing the last pairing tells the integration what changed.""" + camera = _camera(driver, sensors=SENSORS) + _char(camera, "CameraMotionZones", "Active").client_update_value(1) + _char(camera, "CameraMotionZones", "CameraZones").client_update_value( + to_base64_str(ZONES.encode()) + ) + _char(camera, "MotionSensor", "MotionEnabled").client_update_value(False) + camera.motion_zones_changed = MagicMock() + camera.motion_enabled_changed = MagicMock() + + camera.reset_accessory_state() + camera.motion_zones_changed.assert_called_once_with() + camera.motion_enabled_changed.assert_called_once_with(True) + assert camera.camera_zones is None + assert camera.motion_zones_active is False + assert camera.motion_enabled is True + + +async def test_reset_unchanged_calls_no_hooks(driver: Any) -> None: + """Test a reset without stored settings calls no hooks.""" + camera = _camera(driver, sensors=SENSORS) + camera.motion_zones_changed = MagicMock() + camera.motion_enabled_changed = MagicMock() + + camera.reset_accessory_state() + camera.motion_zones_changed.assert_not_called() + camera.motion_enabled_changed.assert_not_called() + + +@pytest.mark.parametrize( + ("service", "name", "value", "hook", "args"), + [ + ("CameraMotionZones", "Active", 1, "motion_zones_changed", ()), + ( + "CameraMotionZones", + "CameraZones", + to_base64_str(ZONES.encode()), + "motion_zones_changed", + (), + ), + ("MotionSensor", "MotionEnabled", False, "motion_enabled_changed", (False,)), + ], +) +async def test_repeated_write_calls_hook_once( + driver: Any, service: str, name: str, value: Any, hook: str, args: tuple[Any, ...] +) -> None: + """Test a hub writing the same value again does not call the hook again.""" + camera = _camera(driver, sensors=SENSORS) + mock = MagicMock() + setattr(camera, hook, mock) + char = _char(camera, service, name) + + char.client_update_value(value) + char.client_update_value(value) + char.client_update_value(value) + mock.assert_called_once_with(*args) + + +@pytest.mark.parametrize( + ("service", "name", "value", "hook"), + [ + ("CameraMotionZones", "Active", 0, "motion_zones_changed"), + ("MotionSensor", "MotionEnabled", True, "motion_enabled_changed"), + ], +) +async def test_write_of_current_value_calls_no_hook( + driver: Any, service: str, name: str, value: Any, hook: str +) -> None: + """Test writing the value the camera already has is no change.""" + camera = _camera(driver, sensors=SENSORS) + mock = MagicMock() + setattr(camera, hook, mock) + + _char(camera, service, name).client_update_value(value) + mock.assert_not_called() + + +async def test_reset_hooks_see_reset_recording(driver: Any) -> None: + """Test the hooks run after the recording state is reset as well.""" + camera = _camera(driver, sensors=SENSORS, recording=True) + _char(camera, "CameraRecordingManagement", "Active").client_update_value(1) + _char(camera, "CameraMotionZones", "Active").client_update_value(1) + seen: list[bool] = [] + camera.motion_zones_changed = lambda: seen.append(camera.recording_active) + + camera.reset_accessory_state() + assert seen == [False] diff --git a/tests/test_hksv3_state.py b/tests/test_hksv3_state.py new file mode 100644 index 00000000..e2ff1dd1 --- /dev/null +++ b/tests/test_hksv3_state.py @@ -0,0 +1,158 @@ +"""Tests for persisting the HKSV 17.99 camera state.""" + +import asyncio +from collections.abc import AsyncIterator +import logging +import pathlib +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest + +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import HKSV3_STATE, Camera + +CAMERA_AID = 2 + + +@pytest.fixture(name="driver") +async def driver_fixture(tmp_path: pathlib.Path) -> AsyncIterator[AccessoryDriver]: + with patch("pyhap.accessory_driver.AccessoryDriver.async_persist"): + yield AccessoryDriver( + loop=asyncio.get_running_loop(), + listen_address="127.0.0.1", + persist_file=str(tmp_path / "accessory.state"), + ) + + +async def _drain() -> None: + # add_job hops through call_soon_threadsafe, then call_soon. + for _ in range(2): + await asyncio.sleep(0) + + +def _camera( + driver: Any, aid: int | None = CAMERA_AID, handler: Any = None +) -> tuple[Camera, Any, Any]: + camera = Camera({"address": "127.0.0.1"}, driver, "Cam", aid=aid) + light = camera.add_preload_service("Lightbulb", chars=["Brightness"]) + on, brightness = ( + light.get_characteristic("On"), + light.get_characteristic("Brightness"), + ) + handler = handler or MagicMock() + camera._persist_hksv3_char(on, "on", handler) + camera._persist_hksv3_char(brightness, "brightness", handler) + camera._restore_hksv3_state() + return camera, on, brightness + + +async def test_write_is_stored_and_restored(driver: Any) -> None: + """Test a controller write survives rebuilding the camera.""" + _, on, brightness = _camera(driver) + on.client_update_value(True) + brightness.client_update_value(40) + await _drain() + assert driver.accessory_state(CAMERA_AID)[HKSV3_STATE] == { + "on": True, + "brightness": 40, + } + # Both writes share one save. + driver.async_persist.assert_called_once() + + _, on, brightness = _camera(driver) + assert on.value is True + assert brightness.value == 40 + + +async def test_restore_skips_invalid_values( + driver: Any, caplog: pytest.LogCaptureFixture +) -> None: + """Test a damaged state entry keeps the default.""" + driver.accessory_state(CAMERA_AID)[HKSV3_STATE] = {"brightness": "bright"} + with caplog.at_level(logging.WARNING): + _, on, brightness = _camera(driver) + assert on.value is False + assert brightness.value == 0 + assert "Ignoring stored brightness" in caplog.text + + +async def test_restore_ignores_other_state(driver: Any) -> None: + """Test a state entry that is not a dict is ignored.""" + driver.accessory_state(CAMERA_AID)[HKSV3_STATE] = ["on"] + _, on, _ = _camera(driver) + assert on.value is False + + +async def test_write_replaces_other_state(driver: Any) -> None: + """Test a write still works when the state entry is not a dict.""" + driver.accessory_state(CAMERA_AID)[HKSV3_STATE] = ["on"] + _, on, _ = _camera(driver) + on.client_update_value(True) + assert driver.accessory_state(CAMERA_AID)[HKSV3_STATE] == {"on": True} + + +async def test_restore_accessory_state(driver: Any) -> None: + """Test the driver restoring state loaded after construction.""" + camera, on, _ = _camera(driver) + driver.accessory_state(CAMERA_AID)[HKSV3_STATE] = {"on": True} + camera.restore_accessory_state() + assert on.value is True + + +async def test_reset_returns_defaults(driver: Any) -> None: + """Test removing the last pairing forgets the stored state.""" + camera, on, brightness = _camera(driver) + on.client_update_value(True) + brightness.client_update_value(40) + await _drain() + driver.async_persist.reset_mock() + + camera.reset_accessory_state() + await _drain() + assert on.value is False + assert brightness.value == 0 + assert HKSV3_STATE not in driver.accessory_state(CAMERA_AID) + driver.async_persist.assert_called_once() + + +@pytest.mark.parametrize( + ("on_value", "brightness_value", "calls"), + [ + (True, 40, [call(False)]), + (False, 40, [call(0)]), + (False, 0, []), + ], +) +async def test_reset_calls_handler( + driver: Any, on_value: bool, brightness_value: int, calls: list[Any] +) -> None: + """Test a reset calls each handler once, only for changed values.""" + handler = MagicMock() + camera, on, brightness = _camera(driver, handler=handler) + on.set_value(on_value) + brightness.set_value(brightness_value) + + camera.reset_accessory_state() + assert handler.call_args_list == calls + + +async def test_without_aid_nothing_is_stored(driver: Any) -> None: + """Test a camera without aid neither stores nor restores.""" + camera, on, _ = _camera(driver, aid=None) + on.client_update_value(True) + camera.reset_accessory_state() + await _drain() + assert driver.state.accessory_states == {} + driver.async_persist.assert_not_called() + + +async def test_save_without_loop(driver: Any, caplog: pytest.LogCaptureFixture) -> None: + """Test a closed loop only logs.""" + _, on, _ = _camera(driver) + with ( + patch.object(driver, "add_job", side_effect=RuntimeError), + caplog.at_level(logging.WARNING), + ): + on.client_update_value(True) + assert "Could not schedule saving the camera state" in caplog.text From 3dea2d51911e9fc6a29ddb79b4a3b0a5573257a1 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:54:16 +0100 Subject: [PATCH 05/56] Add the HKSV 17.99 camera global operating mode A new "global_operating_mode" camera option adds the Camera Global Operating Mode service with the listed optional characteristics, after all other services so their instance IDs stay the same. The settings a controller writes are persisted and reset for a new home; changes from writes and resets call global_operating_mode_changed with the setting and its value. Manually Disabled and Third Party Camera Active are device state, set through set_manually_disabled and set_third_party_camera_active. streaming_allowed combines HomeKit Camera Active, Streaming Enabled and Manually Disabled. While it is False, the camera answers SetupEndpoints with an error and does not start streams. Cameras without the option are unchanged. When the service exists, its HomeKit Camera Active alone turns the camera on or off. The value on the Camera Operating Mode service of classic recording is then ignored, so a value left there cannot keep the camera off. A change closes a running recording and calls homekit_camera_active_changed like before; writing the same value again calls no hook. --- pyhap/camera.py | 176 +++++++++++-- tests/test_camera_operating_mode.py | 388 ++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+), 24 deletions(-) create mode 100644 tests/test_camera_operating_mode.py diff --git a/pyhap/camera.py b/pyhap/camera.py index e898c7b3..cfc0afa8 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -261,6 +261,13 @@ "SupportedVideoRecordingConfiguration", "SupportedAudioRecordingConfiguration", ) +# Global operating mode chars a controller writes, the camera keeps them. +PERSISTED_GLOBAL_OPERATING_MODE_CHARS = ( + "HomeKitCameraActive", + "StreamingEnabled", + "CameraOperatingModeIndicator", + "NightVision", +) class Camera(Accessory): @@ -468,6 +475,16 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: service is added so that HAP clients know the accessory supports it, and :meth:`talkback_audio_received` is called with the received audio for each session that negotiates it. + - global_operating_mode - list of optional characteristics of the + HomeKit Secure Video 17.99 Camera Global Operating Mode service, + e.g. ``["ManuallyDisabled", "NightVision"]``. Opts into the service, + an empty list adds only the required characteristics. Its + HomeKit Camera Active alone turns the camera on or off: the value + of the Camera Operating Mode service that ``recording`` adds is + then ignored for :attr:`homekit_camera_active`, recording and + :attr:`streaming_allowed`. While streaming is not allowed, the + camera refuses to set up live streams. Unknown names raise + ``ValueError``. - start_stream_cmd - string specifying the command to be executed to start the stream. The string can contain the keywords, corresponding to the video and audio configuration that was negotiated between the camera @@ -523,8 +540,9 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: self._hds_listener = None self._hds_connections = set() self._recording_stream = None - self._homekit_camera_active_char = None + self._homekit_camera_active_char: Characteristic | None = None self._motion_status_active_char = None + self._global_operating_mode: Service | None = None # Persisted HKSV 17.99 chars with their state key, default and the # handler a reset calls. self._hksv3_defaults: dict[ @@ -545,6 +563,9 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: self._setup_recording_management(options) if self._sensors: self._setup_camera_capabilities() + # Last, so the instance IDs of the other services stay the same. + if options.get("global_operating_mode") is not None: + self._setup_global_operating_mode(options["global_operating_mode"]) self._restore_hksv3_state() @property @@ -653,11 +674,11 @@ def _setup_recording_management(self, options: dict[str, Any]) -> None: "CameraOperatingMode", chars=["PeriodicSnapshotsActive"] ) operating_mode.configure_char("EventSnapshotsActive", value=1) - self._homekit_camera_active_char = operating_mode.configure_char( - "HomeKitCameraActive", - value=1, - setter_callback=self._handle_homekit_camera_active, + homekit_camera_active = operating_mode.configure_char( + "HomeKitCameraActive", value=1 ) + homekit_camera_active.setter_callback = self._handle_homekit_camera_active + self._homekit_camera_active_char = homekit_camera_active operating_mode.configure_char("PeriodicSnapshotsActive", value=1) # HKSV records on an event trigger; the camera owns a motion sensor and @@ -941,16 +962,104 @@ def recording_audio_active(self) -> bool: self._recording_service.get_characteristic("RecordingAudioActive").value ) + def _setup_global_operating_mode(self, chars: list[str]) -> None: + """Create the Camera Global Operating Mode service (HKSV 17.99).""" + if not isinstance(chars, (list, tuple)): + raise ValueError( + "global_operating_mode must be a list of characteristic names" + ) + optional = self.driver.loader.serv_types["CameraGlobalOperatingMode"][ + "OptionalCharacteristics" + ] + if unknown := [name for name in chars if name not in optional]: + raise ValueError( + "Not an optional Camera Global Operating Mode characteristic: " + + ", ".join(unknown) + ) + service = self.add_preload_service("CameraGlobalOperatingMode", chars=chars) + for char in service.characteristics: + name = char.display_name + # Everything starts on except Night Vision and Manually Disabled. + if name not in ("NightVision", "ManuallyDisabled"): + char.set_value(1, should_notify=False) + if name not in PERSISTED_GLOBAL_OPERATING_MODE_CHARS: + continue + if name == "HomeKitCameraActive": + handler = self._handle_homekit_camera_active + # This value alone decides, the classic one is then ignored. + if self._homekit_camera_active_char is not None: + self._homekit_camera_active_char.setter_callback = None + self._homekit_camera_active_char = char + else: + handler = functools.partial(self._handle_global_operating_mode, name) + self._persist_hksv3_char(char, f"global_operating_mode.{name}", handler) + self._global_operating_mode = service + + @property + def streaming_allowed(self) -> bool: + """Whether the global operating mode allows streaming. + + True without the ``global_operating_mode`` option. While it is False, + the camera refuses to set up live streams. + """ + service = self._global_operating_mode + if service is None: + return True + manually_disabled = self._global_operating_mode_char("ManuallyDisabled") + return ( + self.homekit_camera_active + and bool(service.get_characteristic("StreamingEnabled").value) + and not (manually_disabled is not None and manually_disabled.value) + ) + + def set_manually_disabled(self, disabled: bool) -> None: + """Report the camera being turned off on the device itself. + + Needs ``ManuallyDisabled`` in the ``global_operating_mode`` option. + """ + char = self._global_operating_mode_char("ManuallyDisabled") + if char is not None: + char.set_value(bool(disabled)) + + def set_third_party_camera_active(self, active: bool) -> None: + """Report whether the camera is on for apps outside HomeKit. + + Needs ``ThirdPartyCameraActive`` in the ``global_operating_mode`` option. + """ + char = self._global_operating_mode_char("ThirdPartyCameraActive") + if char is not None: + char.set_value(bool(active)) + + def _global_operating_mode_char(self, name: str) -> Characteristic | None: + if self._global_operating_mode is None: + return None + try: + return self._global_operating_mode.get_characteristic(name) + except ValueError: + return None + + def _handle_global_operating_mode(self, name: str, value: Any) -> None: + self.global_operating_mode_changed(name, value) + + def global_operating_mode_changed(self, name: str, value: Any) -> None: + """React to the controller changing a global operating mode setting. + + ``name`` is the characteristic, e.g. ``"NightVision"``. HomeKit Camera + Active changes call :meth:`homekit_camera_active_changed` instead. Only + changes call it, not a controller writing the same value again. + Override. + """ + @property def homekit_camera_active(self) -> bool: """Whether the camera is on in the Home app. - Only cameras with recording have the setting, all others are on. + Only cameras with recording or the global operating mode have the + setting, all others are on. With the global operating mode only its + value counts. """ - operating_mode = self.get_service("CameraOperatingMode") - if operating_mode is None: - return True - return bool(operating_mode.get_characteristic("HomeKitCameraActive").value) + char = self._homekit_camera_active_char + return char is None or bool(char.value) def set_selected_recording_configuration(self, value: str) -> None: """Handle a write to Selected Camera Recording Configuration (spec HKSV).""" @@ -1307,18 +1416,24 @@ async def _start_stream(self, objs: dict[bytes, Any], reconfigure: bool) -> None stream_idx = session_info["stream_idx"] opts.update(session_info) - success = ( - await self.reconfigure_stream(session_info, opts) - if reconfigure - else await self.start_stream(session_info, opts) - ) + # Turned off after SetupEndpoints. + refused = not reconfigure and not self.streaming_allowed + if refused: + logger.warning("[%s] Streaming is turned off, refusing it", session_id) + success = False + elif reconfigure: + success = await self.reconfigure_stream(session_info, opts) + else: + success = await self.start_stream(session_info, opts) if success: self._streaming_status[stream_idx] = STREAMING_STATUS["STREAMING"] else: - logger.error( - "[%s] Failed to start/reconfigure stream, deleting session.", session_id - ) + if not refused: + logger.error( + "[%s] Failed to start/reconfigure stream, deleting session.", + session_id, + ) self._close_talkback(session_info) del self.sessions[session_id] self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"] @@ -1338,11 +1453,11 @@ def _handle_homekit_camera_active(self, value: Any) -> None: # A camera turned off must not record, and the hub must not treat its # motion sensor as an active trigger. active = bool(value) - assert self._motion_status_active_char is not None # noqa: S101 - # Always notify: a restored value may have bypassed the stored one, - # so set_value alone could see no change. - self._motion_status_active_char.set_value(active, should_notify=False) - self._motion_status_active_char.notify() + if self._motion_status_active_char is not None: + # Always notify: a restored value may have bypassed the stored one, + # so set_value alone could see no change. + self._motion_status_active_char.set_value(active, should_notify=False) + self._motion_status_active_char.notify() if not active and self._recording_stream is not None: self._recording_stream.close(hds_recording.RecordingReason.NOT_ALLOWED) self.homekit_camera_active_changed(active) @@ -1353,7 +1468,8 @@ def homekit_camera_active_changed(self, active: bool) -> None: Override instead of replacing the characteristic's setter. A running recording is already closed when the camera is turned off. Like other setters, every controller write calls this, setting the value locally - does not. + does not. With the ``global_operating_mode`` option only changes call + it, not a controller writing the same value again. """ async def _stop_stream(self, objs: dict[bytes, Any]) -> None: @@ -1476,6 +1592,18 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: objs = tlv.decode(value, from_base64=True) session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]]) + if not self.streaming_allowed: + logger.warning("[%s] Streaming is turned off, refusing it", session_id) + self._management[stream_idx].get_characteristic("SetupEndpoints").set_value( + tlv.encode( + SETUP_TYPES["SESSION_ID"], + session_id.bytes, + SETUP_TYPES["STATUS"], + SETUP_STATUS["ERROR"], + to_base64=True, + ) + ) + return # Extract address info address_tlv = objs[SETUP_TYPES["ADDRESS"]] diff --git a/tests/test_camera_operating_mode.py b/tests/test_camera_operating_mode.py new file mode 100644 index 00000000..19cc7328 --- /dev/null +++ b/tests/test_camera_operating_mode.py @@ -0,0 +1,388 @@ +"""Tests for the HKSV 17.99 global operating mode of pyhap.camera.""" + +import asyncio +from collections.abc import AsyncIterator +import pathlib +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch +from uuid import UUID + +import pytest + +from pyhap import camera as camera_module, hds_recording, tlv +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import SETUP_STATUS, SETUP_TYPES, STREAMING_STATUS, Camera +from pyhap.characteristic import PROP_PERMISSIONS + +CAMERA_AID = 2 +GLOBAL = "CameraGlobalOperatingMode" +OPTIONAL = ["ManuallyDisabled", "NightVision", "ThirdPartyCameraActive"] +VIDEO = { + "codec": { + "profiles": [camera_module.VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"]], + "levels": [camera_module.VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE4_0"]], + }, + "resolutions": [[640, 360, 30]], +} +AUDIO = {"codecs": [{"type": "OPUS", "samplerate": 24}]} +# A stream start request for SESSION_ID. +START_REQUEST = ( + "ARUCAQEBEKzMbMEFY0UVjal0tFCQBpECNAEBAAIJAQEAAgEAAwEAAwsBAoAC" + "AgJoAQMBHgQXAQFjAgQr66FSAwKEAAQEAAAAPwUCYgUDLAEBAgIMAQEBAgEA" + "AwEBBAEeAxYBAW4CBMUInmQDAhgABAQAAKBABgENBAEA" +) +SESSION_ID = UUID("accc6cc1-0563-4515-8da9-74b450900691") +# A SetupEndpoints request for SESSION_ID. +SETUP_REQUEST = ( + "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" + "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" + "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" +) + + +@pytest.fixture(name="driver") +async def driver_fixture(tmp_path: pathlib.Path) -> AsyncIterator[AccessoryDriver]: + with patch("pyhap.accessory_driver.AccessoryDriver.async_persist"): + yield AccessoryDriver( + loop=asyncio.get_running_loop(), + listen_address="127.0.0.1", + persist_file=str(tmp_path / "accessory.state"), + ) + + +def _camera(driver: Any, **options: Any) -> Camera: + return Camera({"address": "127.0.0.1", **options}, driver, "Cam", aid=CAMERA_AID) + + +def _char(camera: Camera, service: str, name: str) -> Any: + found = camera.get_service(service) + assert found is not None + return found.get_characteristic(name) + + +def _values(camera: Camera) -> dict[str | None, Any]: + service = camera.get_service(GLOBAL) + assert service is not None + return {char.display_name: char.value for char in service.characteristics} + + +async def test_without_option(driver: Any) -> None: + """Test cameras without the option are unchanged.""" + camera = _camera(driver) + assert camera.get_service(GLOBAL) is None + assert camera.homekit_camera_active is True + assert camera.streaming_allowed is True + camera.set_manually_disabled(True) + camera.set_third_party_camera_active(False) + + +async def test_required_chars(driver: Any) -> None: + """Test an empty list adds the required chars, all on.""" + camera = _camera(driver, global_operating_mode=[]) + assert _values(camera) == { + "HomeKitCameraActive": 1, + "StreamingEnabled": True, + "CameraOperatingModeIndicator": True, + } + assert camera.streaming_allowed is True + camera.set_manually_disabled(True) + camera.set_third_party_camera_active(False) + assert camera.streaming_allowed is True + + +async def test_optional_chars(driver: Any) -> None: + """Test optional chars and their defaults.""" + camera = _camera(driver, global_operating_mode=OPTIONAL) + assert _values(camera) == { + "HomeKitCameraActive": 1, + "StreamingEnabled": True, + "CameraOperatingModeIndicator": True, + "ManuallyDisabled": False, + "NightVision": False, + "ThirdPartyCameraActive": True, + } + + +async def test_manually_disabled(driver: Any) -> None: + """Test the device turning the camera off stops streaming.""" + camera = _camera(driver, global_operating_mode=OPTIONAL) + camera.set_manually_disabled(True) + assert _char(camera, GLOBAL, "ManuallyDisabled").value is True + assert camera.streaming_allowed is False + assert camera.homekit_camera_active is True + camera.set_manually_disabled(False) + assert camera.streaming_allowed is True + + +async def test_third_party_camera_active(driver: Any) -> None: + """Test the device reports Third Party Camera Active, controllers only read it.""" + camera = _camera(driver, global_operating_mode=OPTIONAL) + camera.global_operating_mode_changed = MagicMock() + char = _char(camera, GLOBAL, "ThirdPartyCameraActive") + assert char.properties[PROP_PERMISSIONS] == ["pr", "ev"] + assert char.setter_callback is None + camera.set_third_party_camera_active(False) + assert char.value is False + assert camera.streaming_allowed is True + camera.global_operating_mode_changed.assert_not_called() + + +@pytest.mark.parametrize( + ("name", "value", "streaming_allowed"), + [ + ("StreamingEnabled", False, False), + ("CameraOperatingModeIndicator", False, True), + ("NightVision", True, True), + ], +) +async def test_writes_call_hook( + driver: Any, name: str, value: bool, streaming_allowed: bool +) -> None: + """Test controller writes reach the hook with the setting and its value.""" + camera = _camera(driver, global_operating_mode=OPTIONAL) + camera.global_operating_mode_changed = MagicMock() + camera.homekit_camera_active_changed = MagicMock() + _char(camera, GLOBAL, name).client_update_value(value) + camera.global_operating_mode_changed.assert_called_once_with(name, value) + camera.homekit_camera_active_changed.assert_not_called() + assert camera.streaming_allowed is streaming_allowed + + +@pytest.mark.parametrize( + ("enabled", "starts", "status", "warnings"), + [ + (True, 1, STREAMING_STATUS["STREAMING"], 0), + (False, 0, STREAMING_STATUS["AVAILABLE"], 1), + ], +) +async def test_stream_start_needs_streaming_allowed( + driver: Any, + enabled: bool, + starts: int, + status: bytes, + warnings: int, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a stream turned off after its setup does not start.""" + camera = _camera(driver, video=VIDEO, audio=AUDIO, global_operating_mode=[]) + camera.start_stream = AsyncMock(return_value=True) + camera.sessions[SESSION_ID] = {"id": SESSION_ID, "stream_idx": 0} + _char(camera, GLOBAL, "StreamingEnabled").client_update_value(enabled) + + await camera._start_stream( + tlv.decode(START_REQUEST, from_base64=True), reconfigure=False + ) + assert camera.start_stream.await_count == starts + assert camera.streaming_status == status + assert (SESSION_ID in camera.sessions) is enabled + assert caplog.text.count("Streaming is turned off") == warnings + assert "Failed to start/reconfigure stream" not in caplog.text + + +@pytest.mark.parametrize( + ("options", "service", "enabled", "status", "sessions"), + [ + ( + {"global_operating_mode": []}, + GLOBAL, + True, + SETUP_STATUS["SUCCESS"], + [SESSION_ID], + ), + ({"global_operating_mode": []}, GLOBAL, False, SETUP_STATUS["ERROR"], []), + ( + {"recording": True}, + "CameraOperatingMode", + False, + SETUP_STATUS["SUCCESS"], + [SESSION_ID], + ), + ], +) +async def test_setup_endpoints_needs_streaming_allowed( + driver: Any, + options: dict[str, Any], + service: str, + enabled: bool, + status: bytes, + sessions: list[UUID], +) -> None: + """Test the camera refuses a stream setup while streaming is turned off. + + Without the global operating mode, turning the camera off does not + refuse streams, as before. + """ + camera = _camera(driver, video=VIDEO, audio=AUDIO, **options) + _char(camera, service, "HomeKitCameraActive").client_update_value(enabled) + setup = _char(camera, "CameraRTPStreamManagement", "SetupEndpoints") + setup.client_update_value(SETUP_REQUEST) + + response = tlv.decode(setup.get_value(), from_base64=True) + assert response[SETUP_TYPES["SESSION_ID"]] == SESSION_ID.bytes + assert response[SETUP_TYPES["STATUS"]] == status + assert list(camera.sessions) == sessions + + +async def test_homekit_camera_active(driver: Any) -> None: + """Test turning the camera off without recording.""" + camera = _camera(driver, global_operating_mode=[]) + camera.homekit_camera_active_changed = MagicMock() + _char(camera, GLOBAL, "HomeKitCameraActive").client_update_value(0) + camera.homekit_camera_active_changed.assert_called_once_with(False) + assert camera.homekit_camera_active is False + assert camera.streaming_allowed is False + + +async def test_global_operating_mode_decides(driver: Any) -> None: + """Test the global operating mode turns a recording camera off.""" + camera = _camera(driver, recording=True, global_operating_mode=[]) + camera.homekit_camera_active_changed = MagicMock() + stream = MagicMock() + camera._recording_stream = stream + status_active = _char(camera, "MotionSensor", "StatusActive") + + _char(camera, GLOBAL, "HomeKitCameraActive").client_update_value(0) + camera.homekit_camera_active_changed.assert_called_once_with(False) + assert camera.homekit_camera_active is False + assert status_active.get_value() is False + stream.close.assert_called_once_with(hds_recording.RecordingReason.NOT_ALLOWED) + + +async def test_operating_mode_ignored(driver: Any) -> None: + """Test the Camera Operating Mode value is ignored next to the global one.""" + camera = _camera(driver, recording=True, global_operating_mode=[]) + camera.homekit_camera_active_changed = MagicMock() + stream = MagicMock() + camera._recording_stream = stream + status_active = _char(camera, "MotionSensor", "StatusActive") + active = _char(camera, GLOBAL, "HomeKitCameraActive") + legacy = _char(camera, "CameraOperatingMode", "HomeKitCameraActive") + + # A value left from before must not keep the camera off. + active.client_update_value(0) + legacy.client_update_value(0) + active.client_update_value(1) + assert camera.homekit_camera_active_changed.call_count == 2 + camera.homekit_camera_active_changed.assert_called_with(True) + assert camera.homekit_camera_active is True + assert camera.streaming_allowed is True + assert status_active.get_value() is True + + legacy.client_update_value(1) + legacy.client_update_value(0) + assert camera.homekit_camera_active_changed.call_count == 2 + assert camera.homekit_camera_active is True + stream.close.assert_called_once() + + +async def test_settings_survive_restart(driver: Any) -> None: + """Test controller writes are restored, the device state is not.""" + camera = _camera(driver, global_operating_mode=OPTIONAL) + _char(camera, GLOBAL, "HomeKitCameraActive").client_update_value(0) + _char(camera, GLOBAL, "StreamingEnabled").client_update_value(False) + _char(camera, GLOBAL, "CameraOperatingModeIndicator").client_update_value(False) + _char(camera, GLOBAL, "NightVision").client_update_value(True) + camera.set_manually_disabled(True) + camera.set_third_party_camera_active(False) + + camera = _camera(driver, global_operating_mode=OPTIONAL) + assert _values(camera) == { + "HomeKitCameraActive": 0, + "StreamingEnabled": False, + "CameraOperatingModeIndicator": False, + "ManuallyDisabled": False, + "NightVision": True, + "ThirdPartyCameraActive": True, + } + camera.global_operating_mode_changed = MagicMock() + camera.homekit_camera_active_changed = MagicMock() + + camera.reset_accessory_state() + camera.homekit_camera_active_changed.assert_called_once_with(True) + assert camera.global_operating_mode_changed.call_args_list == [ + call("StreamingEnabled", True), + call("CameraOperatingModeIndicator", True), + call("NightVision", False), + ] + assert _values(camera) == { + "HomeKitCameraActive": 1, + "StreamingEnabled": True, + "CameraOperatingModeIndicator": True, + "ManuallyDisabled": False, + "NightVision": False, + "ThirdPartyCameraActive": True, + } + + +def _instance_ids( + camera: Camera, +) -> dict[tuple[str | None, str | None], int | None]: + return { + (service.display_name, char.display_name): camera.iid_manager.get_iid(char) + for service in camera.services + for char in service.characteristics + } + + +async def test_instance_ids_unchanged(driver: Any) -> None: + """Test turning the option on keeps the instance IDs of the other services.""" + without = _instance_ids(_camera(driver, recording=True, video=VIDEO, audio=AUDIO)) + with_option = _instance_ids( + _camera( + driver, recording=True, video=VIDEO, audio=AUDIO, global_operating_mode=[] + ) + ) + assert without.items() <= with_option.items() + + +@pytest.mark.parametrize( + ("name", "value", "hook", "args"), + [ + ( + "StreamingEnabled", + False, + "global_operating_mode_changed", + ("StreamingEnabled", False), + ), + ("NightVision", True, "global_operating_mode_changed", ("NightVision", True)), + ("HomeKitCameraActive", 0, "homekit_camera_active_changed", (False,)), + ], +) +async def test_repeated_write_calls_hook_once( + driver: Any, name: str, value: Any, hook: str, args: tuple[Any, ...] +) -> None: + """Test a hub writing the same value again does not call the hook again.""" + camera = _camera(driver, recording=True, global_operating_mode=OPTIONAL) + mock = MagicMock() + setattr(camera, hook, mock) + char = _char(camera, GLOBAL, name) + + char.client_update_value(value) + char.client_update_value(value) + char.client_update_value(value) + mock.assert_called_once_with(*args) + + +async def test_classic_writes_call_hook_every_time(driver: Any) -> None: + """Test cameras without the option keep calling the hook on every write.""" + camera = _camera(driver, recording=True) + camera.homekit_camera_active_changed = MagicMock() + char = _char(camera, "CameraOperatingMode", "HomeKitCameraActive") + + char.client_update_value(0) + char.client_update_value(0) + assert camera.homekit_camera_active_changed.call_args_list == [call(False)] * 2 + + +@pytest.mark.parametrize("chars", [["Brightness"], ["HomeKitCameraActive"]]) +async def test_unknown_chars(driver: Any, chars: list[str]) -> None: + """Test names that are not optional chars of the service raise.""" + with pytest.raises(ValueError, match=chars[0]): + _camera(driver, global_operating_mode=chars) + + +@pytest.mark.parametrize("chars", [True, "NightVision"]) +async def test_option_not_a_list(driver: Any, chars: Any) -> None: + """Test the option must be a list of names.""" + with pytest.raises(ValueError, match="must be a list"): + _camera(driver, global_operating_mode=chars) From 54a7b1f0a54e86760487ecb21cbe082ce2cd3856 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:22:22 +0100 Subject: [PATCH 06/56] Don't fail stop_stream when ffmpeg already exited terminate() raises ProcessLookupError on an exited process, which surfaced as an unretrieved task exception. Ignore it and still reap the process; same for kill(). --- pyhap/camera.py | 7 +++++-- tests/test_camera.py | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index b20cac9e..76922c18 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -18,6 +18,7 @@ import asyncio from collections.abc import AsyncGenerator +import contextlib import functools import hashlib import ipaddress @@ -1560,7 +1561,8 @@ async def stop_stream(self, session_info: dict[str, Any]) -> None: if ffmpeg_process: logger.info("[%s] Stopping stream.", session_id) try: - ffmpeg_process.terminate() + with contextlib.suppress(ProcessLookupError): + ffmpeg_process.terminate() async with async_timeout(2.0): _, stderr = await ffmpeg_process.communicate() logger.debug("Stream command stderr: %s", stderr) @@ -1569,7 +1571,8 @@ async def stop_stream(self, session_info: dict[str, Any]) -> None: "Timeout while waiting for the stream process " "to terminate. Trying with kill." ) - ffmpeg_process.kill() + with contextlib.suppress(ProcessLookupError): + ffmpeg_process.kill() await ffmpeg_process.wait() logger.debug("Stream process stopped.") else: diff --git a/tests/test_camera.py b/tests/test_camera.py index 9d81af8b..46cb438d 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -6,7 +6,7 @@ import socket import struct from typing import Any, cast -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from uuid import UUID import pytest @@ -420,3 +420,22 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: ): assert await acc.start_stream(session_info, safe_config) is True exec_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_stop_stream_process_already_exited(mock_driver: Any) -> None: + """Verify stopping a stream whose ffmpeg already exited does not raise.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + process = Mock() + process.terminate.side_effect = ProcessLookupError + process.communicate = AsyncMock(return_value=(b"", b"")) + session_info = { + "id": UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1"), + "process": process, + } + + await acc.stop_stream(session_info) + + process.terminate.assert_called_once_with() + process.communicate.assert_awaited_once_with() + process.kill.assert_not_called() From 787a52518b1c1f99b5ffc80bc9170f2da645baa9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:25:31 +0100 Subject: [PATCH 07/56] Return -70405 when reading a characteristic without paired read GET /characteristics returned the stored value of every characteristic, including write-only ones such as Identify or a camera key. The HAP spec only allows reads of characteristics with the paired read (pr) permission and answers everything else with -70405. HAP-NodeJS does the same. The getter is not called for such characteristics. The /accessories database already omitted their value; this adds tests for it. --- pyhap/accessory_driver.py | 17 ++++-- tests/test_accessory_driver.py | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index a68eae6c..bdbccad0 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -36,9 +36,10 @@ from pyhap import util from pyhap.accessory import Accessory, Bridge, get_topic -from pyhap.characteristic import Characteristic, CharacteristicError +from pyhap.characteristic import PROP_PERMISSIONS, Characteristic, CharacteristicError from pyhap.const import ( HAP_PERMISSION_NOTIFY, + HAP_PERMISSION_READ, HAP_PROTOCOL_SHORT_VERSION, HAP_REPR_ACCS, HAP_REPR_AID, @@ -955,10 +956,16 @@ def get_characteristics(self, char_ids: Any) -> dict[str, Any]: available = False # A characteristic IID may resolve to a Service or be unknown; - # only a Characteristic can report a value. - if available and isinstance(char, Characteristic): - rep[HAP_REPR_VALUE] = char.get_value() - rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS + # only a Characteristic can report a value, and only when it + # has the paired read permission. + if isinstance(char, Characteristic): + if HAP_PERMISSION_READ not in char.properties[PROP_PERMISSIONS]: + rep[HAP_REPR_STATUS] = ( + HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC + ) + elif available: + rep[HAP_REPR_VALUE] = char.get_value() + rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS except CharacteristicError: logger.error( "%s: Error getting value for characteristic %s.", diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 97627156..d4ebbe8f 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -32,10 +32,12 @@ ) from pyhap.const import ( HAP_PERMISSIONS, + HAP_REPR_ACCS, HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_PID, + HAP_REPR_SERVICES, HAP_REPR_STATUS, HAP_REPR_TTL, HAP_REPR_VALUE, @@ -140,6 +142,109 @@ def test_get_characteristics_honors_availability( getter.assert_called_once_with() +def _add_bridged_or_standalone( + driver: AccessoryDriver, permissions: list[str], bridged: bool +) -> tuple[Accessory, Characteristic, MagicMock]: + """Add an accessory with one int characteristic that has a getter.""" + acc = Accessory(driver, "Test Accessory", aid=2 if bridged else STANDALONE_AID) + service = Service(uuid1(), "Test Service") + char = Characteristic( + "Value", + uuid1(), + {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: permissions}, + ) + getter = MagicMock(return_value=42) + char.getter_callback = getter + service.add_characteristic(char) + acc.add_service(service) + if bridged: + bridge = Bridge(driver, "Test Bridge") + bridge.add_accessory(acc) + driver.add_accessory(bridge) + else: + driver.add_accessory(acc) + return acc, char, getter + + +READ_PERMISSION_CASES = [ + pytest.param( + ["pr"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, HAP_REPR_VALUE: 42}, + 1, + id="read", + ), + pytest.param( + ["pr", "pw", "ev"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, HAP_REPR_VALUE: 42}, + 1, + id="read-write-notify", + ), + pytest.param( + ["pw"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC}, + 0, + id="write-only", + ), + pytest.param( + ["pw", "ev"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC}, + 0, + id="write-notify", + ), + pytest.param( + ["pw", "tw"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC}, + 0, + id="timed-write-only", + ), +] + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize(("permissions", "expected", "reads"), READ_PERMISSION_CASES) +def test_get_characteristics_requires_read_permission( + driver: AccessoryDriver, + bridged: bool, + permissions: list[str], + expected: dict[str, Any], + reads: int, +) -> None: + """A read of a characteristic without paired read returns no value.""" + acc, char, getter = _add_bridged_or_standalone(driver, permissions, bridged) + iid = acc.iid_manager.get_iid(char) + + result = driver.get_characteristics([f"{acc.aid}.{iid}"]) + + assert result == { + HAP_REPR_CHARS: [{HAP_REPR_AID: acc.aid, HAP_REPR_IID: iid, **expected}] + } + assert getter.call_count == reads + + +@pytest.mark.parametrize( + ("permissions", "values", "reads"), + [ + (["pr"], {HAP_REPR_VALUE}, 1), + (["pr", "pw", "ev"], {HAP_REPR_VALUE}, 1), + (["pw"], set(), 0), + (["pw", "ev"], set(), 0), + (["pw", "tw"], set(), 0), + ], +) +def test_get_accessories_value_requires_read_permission( + driver: AccessoryDriver, permissions: list[str], values: set[str], reads: int +) -> None: + """The accessory database only includes values the controller may read.""" + acc, char, getter = _add_bridged_or_standalone(driver, permissions, False) + + (acc_rep,) = driver.get_accessories()[HAP_REPR_ACCS] + (char_rep,) = acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS] + + assert char_rep[HAP_REPR_IID] == acc.iid_manager.get_iid(char) + assert char_rep.keys() & {HAP_REPR_VALUE} == values + assert getter.call_count == reads + + def test_auto_add_aid_mac(driver: AccessoryDriver) -> None: acc = Accessory(driver, "Test Accessory") driver.add_accessory(acc) From 071f96d22df7ad5ee098fc14ffdc9ef9cc3fab5a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:27:00 +0100 Subject: [PATCH 08/56] Answer -70406 for event subscriptions without notify permission Any characteristic could be subscribed to, whether or not it has the notify (ev) permission. A controller could then receive the values that another controller wrote to a write-only characteristic. Per the HAP spec, (un)subscribing a characteristic without ev now answers -70406 and leaves the subscriptions unchanged, the same as HAP-NodeJS. A value in the same request entry is not written. Some tests subscribed to Version, which has no ev permission. They now use CurrentDoorState. --- pyhap/accessory_driver.py | 38 +++++++-- tests/test_accessory_driver.py | 146 +++++++++++++++++++++++++++++++++ tests/test_hap_handler.py | 10 +-- tests/test_hap_protocol.py | 4 +- 4 files changed, 183 insertions(+), 15 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index bdbccad0..bfe143cf 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1021,11 +1021,9 @@ def set_characteristics( :type chars_query: dict """ - # TODO: Add support for chars that do no support notifications. - queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS] - self._notify(queries, client_addr) + not_notifiable = self._notify(queries, client_addr) updates_by_accessories_services: dict[ Accessory, dict[Service, dict[Characteristic, Any]] @@ -1045,12 +1043,20 @@ def set_characteristics( assert primary_accessory is not None # noqa: S101 primary_aid = primary_accessory.aid - for query in queries: - if HAP_REPR_VALUE not in query and not expired: - continue + for aid, iid in not_notifiable: + results[aid][iid] = { + HAP_REPR_STATUS: HAP_SERVER_STATUS.NOTIFICATION_NOT_SUPPORTED + } + for query in queries: aid = query[HAP_REPR_AID] iid = query[HAP_REPR_IID] + if (aid, iid) in not_notifiable: + continue + if HAP_REPR_VALUE not in query and not expired: + # Listed with status 0 if the response has other results. + results[aid][iid] = {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS} + continue value = query.get(HAP_REPR_VALUE) write_response_requested = query.get(HAP_REPR_WRITE_RESPONSE, False) @@ -1184,8 +1190,15 @@ def signal_handler(self, _signal: Any, _frame: Any) -> None: def _notify( self, queries: list[dict[str, Any]], client_addr: tuple[str, int] - ) -> None: - """Notify the driver that the client has subscribed or unsubscribed.""" + ) -> set[tuple[int, int]]: + """Notify the driver that the client has subscribed or unsubscribed. + + Characteristics without the notify permission are left alone; their + ``(aid, iid)`` pairs are returned so the request can report + -70406 for them. + """ + assert self.accessory is not None # noqa: S101 + not_notifiable: set[tuple[int, int]] = set() for query in queries: if HAP_PERMISSION_NOTIFY not in query: continue @@ -1193,7 +1206,16 @@ def _notify( iid = query[HAP_REPR_IID] ev = query[HAP_PERMISSION_NOTIFY] + char = self.accessory.get_characteristic(aid, iid) + if ( + char is not None + and HAP_PERMISSION_NOTIFY not in char.properties[PROP_PERMISSIONS] + ): + not_notifiable.add((aid, iid)) + continue + char_topic = get_topic(aid, iid) action = "Subscribed" if ev else "Unsubscribed" logger.debug("%s client %s to topic %s", action, client_addr, char_topic) self.async_subscribe_client_topic(client_addr, char_topic, ev) + return not_notifiable diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index d4ebbe8f..eddff96b 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -31,6 +31,7 @@ Characteristic, ) from pyhap.const import ( + HAP_PERMISSION_NOTIFY, HAP_PERMISSIONS, HAP_REPR_ACCS, HAP_REPR_AID, @@ -245,6 +246,151 @@ def test_get_accessories_value_requires_read_permission( assert getter.call_count == reads +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize( + "permissions", [["pr", "ev"], ["pr", "pw", "ev"], ["pw", "ev"]] +) +def test_subscribe_with_notify_permission( + driver: AccessoryDriver, bridged: bool, permissions: list[str] +) -> None: + """Characteristics with the notify permission can be (un)subscribed.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + iid = acc.iid_manager.get_iid(char) + client = ("1.2.3.4", 5) + + subscribe = {HAP_REPR_AID: acc.aid, HAP_REPR_IID: iid, HAP_PERMISSION_NOTIFY: True} + assert driver.set_characteristics({HAP_REPR_CHARS: [subscribe]}, client) is None + assert driver.topics == {f"{acc.aid}.{iid}": {client}} + + unsubscribe = {**subscribe, HAP_PERMISSION_NOTIFY: False} + assert driver.set_characteristics({HAP_REPR_CHARS: [unsubscribe]}, client) is None + assert driver.topics == {} + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize("ev", [True, False], ids=["subscribe", "unsubscribe"]) +@pytest.mark.parametrize("permissions", [["pr"], ["pr", "pw"], ["pw"], ["pw", "tw"]]) +def test_subscribe_without_notify_permission( + driver: AccessoryDriver, bridged: bool, ev: bool, permissions: list[str] +) -> None: + """(Un)subscribing a characteristic without notify answers -70406.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + iid = acc.iid_manager.get_iid(char) + client = ("1.2.3.4", 5) + + result = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: iid, HAP_PERMISSION_NOTIFY: ev} + ] + }, + client, + ) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.NOTIFICATION_NOT_SUPPORTED, + } + ] + } + assert driver.topics == {} + + +def test_refused_subscription_lists_the_others(driver: AccessoryDriver) -> None: + """A 207 for a refused subscription also lists the accepted ones.""" + acc = Accessory(driver, "Test Accessory", aid=STANDALONE_AID) + service = Service(uuid1(), "Test Service") + notifiable = Characteristic( + "Notifiable", + uuid1(), + {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr", "ev"]}, + ) + silent = Characteristic( + "Silent", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr"]} + ) + service.add_characteristic(notifiable) + service.add_characteristic(silent) + acc.add_service(service) + driver.add_accessory(acc) + ok_iid = acc.iid_manager.get_iid(notifiable) + refused_iid = acc.iid_manager.get_iid(silent) + client = ("1.2.3.4", 5) + + result = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: ok_iid, + HAP_PERMISSION_NOTIFY: True, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: refused_iid, + HAP_PERMISSION_NOTIFY: True, + }, + ] + }, + client, + ) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: refused_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.NOTIFICATION_NOT_SUPPORTED, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: ok_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, + }, + ] + } + assert driver.topics == {f"{acc.aid}.{ok_iid}": {client}} + + +@pytest.mark.parametrize("ev", [True, False], ids=["subscribe", "unsubscribe"]) +def test_write_with_subscribe_without_notify_permission( + driver: AccessoryDriver, ev: bool +) -> None: + """A write combined with a refused subscription is not applied.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw"], False) + iid = acc.iid_manager.get_iid(char) + setter = MagicMock() + char.setter_callback = setter + + result = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: iid, + HAP_PERMISSION_NOTIFY: ev, + HAP_REPR_VALUE: 5, + } + ] + }, + ("1.2.3.4", 5), + ) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.NOTIFICATION_NOT_SUPPORTED, + } + ] + } + setter.assert_not_called() + assert driver.topics == {} + + def test_auto_add_aid_mac(driver: AccessoryDriver) -> None: acc = Accessory(driver, "Test Accessory") driver.add_accessory(acc) diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 98590bea..4ae0378e 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -727,7 +727,7 @@ def test_handle_set_handle_set_characteristics_encrypted_pid_missing_prepare( response = hap_handler.HAPResponse() handler.response = response handler.request_body = ( - b'{"pid":123,"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"pid":123,"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) handler.handle_set_characteristics() @@ -759,7 +759,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_prepare( response = hap_handler.HAPResponse() handler.response = response handler.request_body = ( - b'{"pid":123,"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"pid":123,"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) handler.handle_set_characteristics() @@ -797,7 +797,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_multiple_prepare( response = hap_handler.HAPResponse() handler.response = response handler.request_body = ( - b'{"pid":123,"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"pid":123,"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) handler.handle_set_characteristics() @@ -851,7 +851,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_expired_ttl( response = hap_handler.HAPResponse() handler.response = response handler.request_body = ( - b'{"pid":123,"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"pid":123,"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) handler.handle_set_characteristics() @@ -883,7 +883,7 @@ def test_handle_set_handle_set_characteristics_encrypted_with_wrong_pid( response = hap_handler.HAPResponse() handler.response = response handler.request_body = ( - b'{"pid":456,"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"pid":456,"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) handler.handle_set_characteristics() diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index c5b0ad37..749e5b00 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -433,10 +433,10 @@ def test_set_characteristics_with_crypto(driver: AccessoryDriver) -> None: hap_proto.data_received( b"PUT /characteristics HTTP/1.1\r\n" b"Host: HASS12\\032AD1C22._hap._tcp.local\r\n" - b"Content-Length: 49\r\n" + b"Content-Length: 50\r\n" b"Content-Type: application/hap+json\r\n" b"\r\n" - b'{"characteristics":[{"aid":1,"iid":9,"ev":true}]}' + b'{"characteristics":[{"aid":1,"iid":11,"ev":true}]}' ) hap_proto.close() From 3fd5a1b451b085c44180a7b698ce6e5d583dc007 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:45:10 +0100 Subject: [PATCH 09/56] Return -70404 when writing a characteristic without paired write A value written to a characteristic without the pw permission still reached its setter. Answer it with -70404 like HAP-NodeJS instead, without calling the characteristic, service or accessory setters. A subscription in the same entry and the other characteristics of the request are still handled. --- pyhap/accessory_driver.py | 10 +++ tests/test_accessory_driver.py | 132 ++++++++++++++++++++++++++++++++- tests/test_hap_handler.py | 2 +- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index bfe143cf..c9f97599 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -40,6 +40,7 @@ from pyhap.const import ( HAP_PERMISSION_NOTIFY, HAP_PERMISSION_READ, + HAP_PERMISSION_WRITE, HAP_PROTOCOL_SHORT_VERSION, HAP_REPR_ACCS, HAP_REPR_AID, @@ -1075,6 +1076,15 @@ def set_characteristics( if char is None: continue + if ( + value is not None + and HAP_PERMISSION_WRITE not in char.properties[PROP_PERMISSIONS] + ): + results[aid][iid] = { + HAP_REPR_STATUS: HAP_SERVER_STATUS.READ_ONLY_CHARACTERISTIC + } + continue + set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST set_result_value = None diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index eddff96b..92c582ac 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -32,6 +32,7 @@ ) from pyhap.const import ( HAP_PERMISSION_NOTIFY, + HAP_PERMISSION_WRITE, HAP_PERMISSIONS, HAP_REPR_ACCS, HAP_REPR_AID, @@ -52,7 +53,7 @@ CHAR_PROPS = { PROP_FORMAT: HAP_FORMAT_INT, - PROP_PERMISSIONS: HAP_PERMISSION_READ, + PROP_PERMISSIONS: [HAP_PERMISSION_READ, HAP_PERMISSION_WRITE], } @@ -391,6 +392,135 @@ def test_write_with_subscribe_without_notify_permission( assert driver.topics == {} +def _write_value( + driver: AccessoryDriver, acc: Accessory, char: Characteristic, **query: Any +) -> tuple[dict[str, Any] | None, MagicMock, MagicMock]: + """Write 5 to ``char`` and return the result and both setter mocks.""" + setter = MagicMock() + char.setter_callback = setter + service_setter = MagicMock() + assert char.service is not None + char.service.setter_callback = service_setter + iid = acc.iid_manager.get_iid(char) + result = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: iid, HAP_REPR_VALUE: 5, **query} + ] + }, + ("1.2.3.4", 5), + ) + return result, setter, service_setter + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize("permissions", [["pr"], ["pr", "ev"]]) +def test_write_without_write_permission( + driver: AccessoryDriver, bridged: bool, permissions: list[str] +) -> None: + """A write to a characteristic without paired write is refused.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + + result, setter, service_setter = _write_value(driver, acc, char) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_STATUS: HAP_SERVER_STATUS.READ_ONLY_CHARACTERISTIC, + } + ] + } + setter.assert_not_called() + service_setter.assert_not_called() + assert char.value == 0 + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize("permissions", [["pw"], ["pr", "pw"], ["pr", "pw", "ev"]]) +def test_write_with_write_permission( + driver: AccessoryDriver, bridged: bool, permissions: list[str] +) -> None: + """A write to a characteristic with paired write is applied.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + + result, setter, service_setter = _write_value(driver, acc, char) + + assert result is None + setter.assert_called_once_with(5) + service_setter.assert_called_once_with({char.display_name: 5}) + assert char.value == 5 + + +def test_write_with_subscribe_without_write_permission( + driver: AccessoryDriver, +) -> None: + """A subscription is applied even when the write in the same entry is not.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "ev"], False) + client = ("1.2.3.4", 5) + iid = acc.iid_manager.get_iid(char) + + result, setter, _ = _write_value(driver, acc, char, **{HAP_PERMISSION_NOTIFY: True}) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.READ_ONLY_CHARACTERISTIC, + } + ] + } + setter.assert_not_called() + assert driver.topics == {f"{acc.aid}.{iid}": {client}} + + +def test_refused_write_does_not_block_others(driver: AccessoryDriver) -> None: + """A refused write leaves the other characteristics of the request alone.""" + acc = Accessory(driver, "Test Accessory", aid=STANDALONE_AID) + service = Service(uuid1(), "Test Service") + read_only = Characteristic( + "ReadOnly", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr"]} + ) + writable = Characteristic("Writable", uuid1(), CHAR_PROPS) + service.add_characteristic(read_only, writable) + acc.add_service(service) + driver.add_accessory(acc) + setter = MagicMock() + writable.setter_callback = setter + read_only_iid = acc.iid_manager.get_iid(read_only) + writable_iid = acc.iid_manager.get_iid(writable) + + result = driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: read_only_iid, HAP_REPR_VALUE: 5}, + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: writable_iid, HAP_REPR_VALUE: 5}, + ] + }, + ("1.2.3.4", 5), + ) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: read_only_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.READ_ONLY_CHARACTERISTIC, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: writable_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, + }, + ] + } + setter.assert_called_once_with(5) + assert read_only.value == 0 + assert writable.value == 5 + + def test_auto_add_aid_mac(driver: AccessoryDriver) -> None: acc = Accessory(driver, "Test Accessory") driver.add_accessory(acc) diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 4ae0378e..9dc91435 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -930,7 +930,7 @@ def _mock_failure(*_: Any) -> NoReturn: response = hap_handler.HAPResponse() handler.response = response - handler.request_body = b'{"characteristics":[{"aid":1,"iid":11,"value":1}]}' + handler.request_body = b'{"characteristics":[{"aid":1,"iid":12,"value":1}]}' handler.handle_set_characteristics() assert response.status_code == 207 From 93d17d357687823b77fea8ea94a8c1a5db77be97 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:07:16 +0100 Subject: [PATCH 10/56] Ignore timed writes with an unknown or expired pid A write carrying a pid that was never prepared on this connection, was already used or has outlived its TTL was still applied, and its event subscriptions were changed. Only characteristics without a value got -70410. The HAP specification says such a request must be ignored and answered with -70410. Do that for every characteristic in the request, as HAP-NodeJS does. The TTL is now measured with the monotonic clock, so a wall clock jump, e.g. from NTP right after boot, can't expire a timed write. --- pyhap/accessory_driver.py | 31 +++++-- tests/test_accessory_driver.py | 2 +- tests/test_timed_write.py | 164 +++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 tests/test_timed_write.py diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index bd05a8c2..ca4a61f7 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1043,6 +1043,22 @@ def set_characteristics( """ queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS] + if HAP_REPR_PID in chars_query and not self._take_prepared_write( + chars_query[HAP_REPR_PID], client_addr + ): + # The spec requires a timed write that was never prepared, was + # already used or has expired to be ignored as a whole. + return { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: query[HAP_REPR_AID], + HAP_REPR_IID: query[HAP_REPR_IID], + HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST, + } + for query in queries + ] + } + not_notifiable = self._notify(queries, client_addr) updates_by_accessories_services: dict[ @@ -1053,12 +1069,6 @@ def set_characteristics( ) char_to_iid: dict[Characteristic, int] = {} - expired = False - if HAP_REPR_PID in chars_query: - pid = chars_query[HAP_REPR_PID] - expire_time = self.prepared_writes.get(client_addr, {}).pop(pid, None) - expired = expire_time is None or time.time() > expire_time - primary_accessory = self.accessory assert primary_accessory is not None # noqa: S101 primary_aid = primary_accessory.aid @@ -1073,7 +1083,7 @@ def set_characteristics( iid = query[HAP_REPR_IID] if (aid, iid) in not_notifiable: continue - if HAP_REPR_VALUE not in query and not expired: + if HAP_REPR_VALUE not in query: # Listed with status 0 if the response has other results. results[aid][iid] = {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS} continue @@ -1170,6 +1180,11 @@ def set_characteristics( return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None + def _take_prepared_write(self, pid: Any, client_addr: tuple[str, int]) -> bool: + """Consume the prepared write ``pid`` and return whether it is still valid.""" + expires_at = self.prepared_writes.get(client_addr, {}).pop(pid, None) + return expires_at is not None and time.monotonic() <= expires_at + def prepare( self, prepare_query: dict[str, Any], client_addr: tuple[str, int] ) -> dict[str, int]: @@ -1189,7 +1204,7 @@ def prepare( try: ttl = prepare_query[HAP_REPR_TTL] pid = prepare_query[HAP_REPR_PID] - now = time.time() + now = time.monotonic() pending = self.prepared_writes.setdefault(client_addr, {}) # Prune expired entries and cap the number of pending writes so a # paired client cannot grow prepared_writes without bound by diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 475a3c7f..a2975924 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1817,7 +1817,7 @@ def test_prepare_bounds_prepared_writes(driver: AccessoryDriver) -> None: # An absurd TTL is clamped to the maximum. driver.prepare({HAP_REPR_TTL: 10_000_000_000, HAP_REPR_PID: 1}, client_addr) assert ( - driver.prepared_writes[client_addr][1] - time.time() + driver.prepared_writes[client_addr][1] - time.monotonic() <= MAX_PREPARED_WRITE_TTL_SECONDS ) diff --git a/tests/test_timed_write.py b/tests/test_timed_write.py new file mode 100644 index 00000000..a0f5c969 --- /dev/null +++ b/tests/test_timed_write.py @@ -0,0 +1,164 @@ +"""Tests for timed writes (prepare, then write with the pid).""" + +from collections.abc import Iterator +from typing import Any, NamedTuple +from unittest.mock import MagicMock, patch + +import pytest + +from pyhap.accessory import Accessory, get_topic +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import ( + HAP_PERMISSION_NOTIFY, + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_PID, + HAP_REPR_STATUS, + HAP_REPR_TTL, + HAP_REPR_VALUE, + HAP_SERVER_STATUS, +) +from pyhap.service import Service + +CLIENT = ("1.2.3.4", 5000) +NOW = 1000.0 + + +class WritableChar(NamedTuple): + """A characteristic of the test accessory and its setter.""" + + setter: MagicMock + aid: int + iid: int + + +def _writable(acc: Accessory, service: Service, name: str) -> WritableChar: + """Give the char ``name`` of ``service`` a mock setter.""" + setter = MagicMock() + char = service.configure_char(name, setter_callback=setter) + iid = acc.iid_manager.get_iid(char) + assert acc.aid is not None + assert iid is not None + return WritableChar(setter, acc.aid, iid) + + +@pytest.fixture +def chars(driver: AccessoryDriver) -> Iterator[dict[str, WritableChar]]: + """Add a garage door opener with a camera operating mode service. + + Target Door State is a plain write characteristic that iOS still writes + with timed writes, HomeKit Camera Active has the timed write permission. + """ + acc = Accessory(driver, "Garage") + door = driver.loader.get_service("GarageDoorOpener") + camera = driver.loader.get_service("CameraOperatingMode") + acc.add_service(door, camera) + driver.add_accessory(acc) + with patch.object(driver, "publish"): + yield { + "TargetDoorState": _writable(acc, door, "TargetDoorState"), + "HomeKitCameraActive": _writable(acc, camera, "HomeKitCameraActive"), + } + + +@pytest.fixture +def door(chars: dict[str, WritableChar]) -> WritableChar: + """Return the Target Door State of the test accessory.""" + return chars["TargetDoorState"] + + +def _write(aid: int, iid: int, pid: int) -> dict[str, Any]: + """Return a timed write of value 1 that also subscribes to the char.""" + return { + HAP_REPR_PID: pid, + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: aid, + HAP_REPR_IID: iid, + HAP_REPR_VALUE: 1, + HAP_PERMISSION_NOTIFY: True, + } + ], + } + + +def test_prepared_write_is_applied( + driver: AccessoryDriver, + door: WritableChar, +) -> None: + """Test a write with a prepared pid inside its TTL is applied.""" + with patch("pyhap.accessory_driver.time.monotonic", return_value=NOW) as now: + driver.prepare({HAP_REPR_TTL: 2500, HAP_REPR_PID: 7}, CLIENT) + now.return_value = NOW + 2.5 + assert driver.set_characteristics(_write(door.aid, door.iid, 7), CLIENT) is None + + door.setter.assert_called_once_with(1) + assert CLIENT in driver.topics[get_topic(door.aid, door.iid)] + + +@pytest.mark.parametrize( + ("write_pid", "delay"), + [(8, 0.0), (7, 2.6)], + ids=["unknown_pid", "expired_pid"], +) +def test_unprepared_write_is_ignored( + driver: AccessoryDriver, + door: WritableChar, + write_pid: int, + delay: float, +) -> None: + """Test a write with an unknown or expired pid is ignored as a whole.""" + with patch("pyhap.accessory_driver.time.monotonic", return_value=NOW) as now: + driver.prepare({HAP_REPR_TTL: 2500, HAP_REPR_PID: 7}, CLIENT) + now.return_value = NOW + delay + response = driver.set_characteristics( + _write(door.aid, door.iid, write_pid), CLIENT + ) + + assert response == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: door.aid, + HAP_REPR_IID: door.iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST, + } + ] + } + door.setter.assert_not_called() + assert get_topic(door.aid, door.iid) not in driver.topics + + +def test_prepared_pid_is_used_once( + driver: AccessoryDriver, + door: WritableChar, +) -> None: + """Test a prepared pid cannot be replayed.""" + driver.prepare({HAP_REPR_TTL: 2500, HAP_REPR_PID: 7}, CLIENT) + assert driver.set_characteristics(_write(door.aid, door.iid, 7), CLIENT) is None + + response = driver.set_characteristics(_write(door.aid, door.iid, 7), CLIENT) + + assert response is not None + assert response[HAP_REPR_CHARS][0][HAP_REPR_STATUS] == ( + HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + ) + door.setter.assert_called_once_with(1) + + +def test_prepared_pid_belongs_to_its_connection( + driver: AccessoryDriver, + door: WritableChar, +) -> None: + """Test a pid prepared on one connection is unknown on another.""" + driver.prepare({HAP_REPR_TTL: 2500, HAP_REPR_PID: 7}, CLIENT) + + response = driver.set_characteristics( + _write(door.aid, door.iid, 7), ("1.2.3.4", 5001) + ) + + assert response is not None + assert response[HAP_REPR_CHARS][0][HAP_REPR_STATUS] == ( + HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + ) + door.setter.assert_not_called() From e09ecbdd58dd6119b98456d0431d774d51fdd272 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:33:23 +0100 Subject: [PATCH 11/56] Split building the characteristics write response into helpers No behaviour change. The next commit fills in the response after awaiting async setters, which needs the result building in the driver and the HTTP response filling in the handler to work on their own. --- pyhap/accessory_driver.py | 35 +++++++++++++++++++++-------------- pyhap/hap_handler.py | 22 +++++++++++++++------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 5ab915e2..7794081f 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -156,6 +156,26 @@ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any: return HAP_SERVER_STATUS.SUCCESS +def _set_characteristics_response( + results: dict[int, dict[int, dict[str, Any]]], +) -> dict[str, Any] | None: + """Build the write response, or None if every write succeeded without a value.""" + characteristics = [] + nonempty_results_exist = False + for aid, iid_results in results.items(): + for iid, result in iid_results.items(): + result[HAP_REPR_AID] = aid + result[HAP_REPR_IID] = iid + characteristics.append(result) + if ( + result[HAP_REPR_STATUS] != HAP_SERVER_STATUS.SUCCESS + or HAP_REPR_VALUE in result + ): + nonempty_results_exist = True + + return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None + + class AccessoryMDNSServiceInfo(ServiceInfo): """A mDNS service info representation of an accessory.""" @@ -1194,20 +1214,7 @@ def set_characteristics( for char in chars: aid_results[char_to_iid[char]][HAP_REPR_STATUS] = set_result - characteristics = [] - nonempty_results_exist = False - for aid, iid_results in results.items(): - for iid, result in iid_results.items(): - result[HAP_REPR_AID] = aid - result[HAP_REPR_IID] = iid - characteristics.append(result) - if ( - result[HAP_REPR_STATUS] != HAP_SERVER_STATUS.SUCCESS - or HAP_REPR_VALUE in result - ): - nonempty_results_exist = True - - return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None + return _set_characteristics_response(results) def _take_prepared_write(self, pid: Any, client_addr: tuple[str, int]) -> bool: """Consume the prepared write ``pid`` and return whether it is still valid.""" diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 83a47380..a30a388b 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -711,16 +711,24 @@ def handle_set_characteristics(self) -> None: "%s: Set characteristics content: %s", self.client_address, requested_chars ) - response = self.accessory_handler.set_characteristics( + result = self.accessory_handler.set_characteristics( requested_chars, cast("tuple[str, int]", self.client_address) ) - if response is None: - self.send_response(HTTPStatus.NO_CONTENT) - return + assert self.response is not None # noqa: S101 + self._fill_set_characteristics_response(self.response, result) - self.send_response(HTTPStatus.MULTI_STATUS) - self.send_header("Content-Type", self.JSON_RESPONSE_TYPE) - self.end_response(to_hap_json(response)) + def _fill_set_characteristics_response( + self, response: HAPResponse, result: dict[str, Any] | None + ) -> None: + """Fill in the response to a characteristics write.""" + if result is None: + response.status_code = HTTPStatus.NO_CONTENT.value + response.reason = HTTPStatus.NO_CONTENT.phrase + return + response.status_code = HTTPStatus.MULTI_STATUS.value + response.reason = HTTPStatus.MULTI_STATUS.phrase + response.headers.append(("Content-Type", self.JSON_RESPONSE_TYPE)) + response.body = to_hap_json(result) def handle_prepare(self) -> None: """Handles a client request to prepare to write.""" From 1ef0721abc04d43ec4367203fbd97151e439394f Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:28:30 +0100 Subject: [PATCH 12/56] Only send write responses for characteristics with wr permission A write with "r": true got the setter's return value back for any characteristic. The HAP spec ties write responses to the write response (wr) permission, and HAP-NodeJS ignores the request for other characteristics. Do the same. When a write response is sent, it becomes the characteristic's value instead of the written one, which HAP-NodeJS does as well. The sent value is the stored one, after the usual format checks. A response the characteristic can't hold is still sent and the written value is kept. The write response tests are now parametrized and also cover a wr characteristic without paired read: neither the written value nor the response can be read back. --- pyhap/accessory_driver.py | 33 ++++++- pyhap/characteristic.py | 4 +- tests/test_accessory_driver.py | 169 +++++++++++++++++---------------- 3 files changed, 122 insertions(+), 84 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index c9f97599..bd05a8c2 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -41,6 +41,7 @@ HAP_PERMISSION_NOTIFY, HAP_PERMISSION_READ, HAP_PERMISSION_WRITE, + HAP_PERMISSION_WRITE_RESPONSE, HAP_PROTOCOL_SHORT_VERSION, HAP_REPR_ACCS, HAP_REPR_AID, @@ -87,6 +88,24 @@ MAX_PREPARED_WRITE_TTL_SECONDS = 30.0 +def _store_write_response(char: Characteristic, response: Any) -> Any: + """Keep the write response as the value and return what to send. + + The written value of a write response characteristic, such as an SDP + offer, is a request. What the accessory answered is its state. + """ + try: + char.value = char.to_valid_value(response) + except ValueError: + logger.debug( + "%s: keeping the written value, the write response is invalid: %s", + char.display_name, + response, + ) + return response + return char.value + + def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]: """Process an characteristic setter callback trapping and logging all exceptions.""" try: @@ -1059,7 +1078,6 @@ def set_characteristics( results[aid][iid] = {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS} continue value = query.get(HAP_REPR_VALUE) - write_response_requested = query.get(HAP_REPR_WRITE_RESPONSE, False) if aid == primary_aid: acc = primary_accessory @@ -1093,8 +1111,17 @@ def set_characteristics( char, value, client_addr ) - if set_result_value is not None and write_response_requested: - result = {HAP_REPR_STATUS: set_result, HAP_REPR_VALUE: set_result_value} + # Only characteristics with the write response permission answer + # a write with a value, and only when the controller asks for it. + if ( + set_result_value is not None + and query.get(HAP_REPR_WRITE_RESPONSE, False) + and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] + ): + result = { + HAP_REPR_STATUS: set_result, + HAP_REPR_VALUE: _store_write_response(char, set_result_value), + } else: result = {HAP_REPR_STATUS: set_result} diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index bcbdd3e2..a0b09a69 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -201,7 +201,9 @@ def __init__( self._value = self._get_default_value() self.getter_callback: Callable[[], Any] | None = None # Setters may take just the value or (value, sender_client_addr); see - # ``_setter_wants_addr``. The return value is used for write responses. + # ``_setter_wants_addr``. The return value is sent back as the write + # response when the characteristic has the "wr" permission and the + # controller asked for one. self.setter_callback: Callable[..., Any] | None = None # Cache of whether ``setter_callback`` opts into the client address, # recomputed only when the setter object itself changes. diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 92c582ac..475a3c7f 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -609,109 +609,118 @@ def test_listen_address(kwargs: dict[str, Any], expected: Any) -> None: assert server.call_args.args[0] == (expected, 51234) -def test_write_response_returned_when_not_requested(driver: AccessoryDriver) -> None: - bridge = Bridge(driver, "mybridge") - acc = Accessory(driver, "TestAcc", aid=2) - service = Service(uuid1(), "NFCAccess") - char_nfc_access_control_point = Characteristic( - "NFCAccessControlPoint", uuid1(), CHAR_PROPS - ) - service.add_characteristic(char_nfc_access_control_point) - - mock_callback = MagicMock() - service.setter_callback = mock_callback - - def setter_with_write_response(value: int = 0) -> int: - return 1 - - char_nfc_access_control_point.setter_callback = setter_with_write_response - acc.add_service(service) - - char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[ - HAP_REPR_IID - ] - - bridge.add_accessory(acc) - driver.add_accessory(bridge) - - response = driver.set_characteristics( +def _write( + driver: AccessoryDriver, acc: Accessory, char: Characteristic, **query: Any +) -> dict[str, Any] | None: + """Write 0 to ``char`` from a mock client with extra query fields.""" + return driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), HAP_REPR_VALUE: 0, - HAP_REPR_WRITE_RESPONSE: False, + **query, } ] }, - "mock_addr", # ty: ignore[invalid-argument-type] + ("1.2.3.4", 5), ) - assert response is None - response = driver.set_characteristics( - { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, - HAP_REPR_VALUE: 0, - } - ] - }, - "mock_addr", # ty: ignore[invalid-argument-type] - ) - assert response is None +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize("permissions", [["pr", "pw", "wr"], ["pw", "wr"]]) +@pytest.mark.parametrize(("returned", "sent"), [(1, 1), (1.7, 1)]) +def test_write_response_returned_when_requested( + driver: AccessoryDriver, + bridged: bool, + permissions: list[str], + returned: Any, + sent: int, +) -> None: + """A write response carries the setter's value as the char stores it.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + service_setter = MagicMock() + assert char.service is not None + char.service.setter_callback = service_setter + char.setter_callback = MagicMock(return_value=returned) -def test_write_response_returned_when_requested(driver: AccessoryDriver) -> None: - bridge = Bridge(driver, "mybridge") - acc = Accessory(driver, "TestAcc", aid=2) - service = Service(uuid1(), "NFCAccess") - char_nfc_access_control_point = Characteristic( - "NFCAccessControlPoint", uuid1(), CHAR_PROPS - ) - service.add_characteristic(char_nfc_access_control_point) + response = _write(driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}) - mock_callback = MagicMock() - service.setter_callback = mock_callback + assert response == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, + HAP_REPR_VALUE: sent, + } + ] + } + char.setter_callback.assert_called_once_with(0) + service_setter.assert_called_once_with({"Value": 0}) + # Like HAP-NodeJS, the response is the value from now on. + assert char.value == sent - def setter_with_write_response(value: int = 0) -> int: - return 1 - char_nfc_access_control_point.setter_callback = setter_with_write_response - acc.add_service(service) +@pytest.mark.parametrize( + ("permissions", "query"), + [ + pytest.param(["pr", "pw", "wr"], {}, id="not-requested"), + pytest.param( + ["pr", "pw", "wr"], {HAP_REPR_WRITE_RESPONSE: False}, id="declined" + ), + pytest.param( + ["pr", "pw"], {HAP_REPR_WRITE_RESPONSE: True}, id="no-wr-permission" + ), + pytest.param(["pw"], {HAP_REPR_WRITE_RESPONSE: True}, id="write-only"), + ], +) +def test_write_response_not_returned( + driver: AccessoryDriver, permissions: list[str], query: dict[str, Any] +) -> None: + """Without a request or the wr permission the write has no response body.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, False) + char.setter_callback = MagicMock(return_value=1) - char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[ - HAP_REPR_IID - ] + assert _write(driver, acc, char, **query) is None + char.setter_callback.assert_called_once_with(0) + assert char.value == 0 - bridge.add_accessory(acc) - driver.add_accessory(bridge) - response = driver.set_characteristics( - { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, - HAP_REPR_VALUE: 0, - HAP_REPR_WRITE_RESPONSE: True, - } - ] - }, - "mock_addr", # ty: ignore[invalid-argument-type] - ) - assert response == { +def test_invalid_write_response_keeps_written_value(driver: AccessoryDriver) -> None: + """A response the char can't hold is still sent, the written value stays.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], False) + char.setter_callback = MagicMock(return_value="not a number") + + response = _write(driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}) + + assert response is not None + assert response[HAP_REPR_CHARS][0][HAP_REPR_VALUE] == "not a number" + assert char.value == 0 + + +def test_write_response_value_of_write_only_char_not_readable( + driver: AccessoryDriver, +) -> None: + """Neither the written nor the response value of a wr char without pr leaks.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pw", "wr"], False) + char.setter_callback = MagicMock(return_value=1) + iid = acc.iid_manager.get_iid(char) + + _write(driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}) + + assert driver.get_characteristics([f"{acc.aid}.{iid}"]) == { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, - HAP_REPR_STATUS: 0, - HAP_REPR_VALUE: 1, - }, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC, + } ] } + (acc_rep,) = driver.get_accessories()[HAP_REPR_ACCS] + assert HAP_REPR_VALUE not in acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0] def test_service_callbacks(driver: AccessoryDriver) -> None: From 9ef52f7aac961ab0f886a35e02380b2b3900c09a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:08:03 +0100 Subject: [PATCH 13/56] Check the timed write permission on writes The HAP specification requires a plain write to a characteristic with the timed write ("tw") permission to be refused with -70410. HAP-NodeJS does this. A Home hub has been seen writing HomeKit Camera Active without a timed write, and the HomeKit Secure Video guide gives Streaming Enabled and Motion Enabled tw as well. Other controllers may not support timed writes at all. So the check is opt-in: with enforce_timed_writes=True the driver refuses such a write, otherwise it applies it and only logs at debug level. --- pyhap/accessory_driver.py | 31 +++++++++++++++- pyhap/const.py | 1 + tests/test_timed_write.py | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index ca4a61f7..5ab915e2 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -40,6 +40,7 @@ from pyhap.const import ( HAP_PERMISSION_NOTIFY, HAP_PERMISSION_READ, + HAP_PERMISSION_TIMED_WRITE, HAP_PERMISSION_WRITE, HAP_PERMISSION_WRITE_RESPONSE, HAP_PROTOCOL_SHORT_VERSION, @@ -249,6 +250,7 @@ def __init__( async_zeroconf_instance: Any = None, zeroconf_server: Any = None, advertise: bool = True, + enforce_timed_writes: bool = False, ) -> None: """Initialize a new AccessoryDriver object. @@ -303,6 +305,15 @@ def __init__( the driver starts. Pass False to run the HAP server without mDNS advertising, e.g. for tests or benchmarks. :type advertise: bool + + :param enforce_timed_writes: Whether to reject a plain write to a + characteristic with the timed write ("tw") permission with -70410, + as the HAP specification requires. When False, such a write is + applied and only logged at debug level: a Home hub has been seen + writing HomeKit Camera Active without a timed write, and the HomeKit + Secure Video guide gives Streaming Enabled and Motion Enabled tw too, + so enforcing it would break HKSV with such hubs. + :type enforce_timed_writes: bool """ if loop is None: if sys.platform == "win32": @@ -335,6 +346,7 @@ def __init__( self.stop_event = threading.Event() self.safe_mode = False + self.enforce_timed_writes = enforce_timed_writes self.mdns_service_info: ServiceInfo | None = None self._srp_verifier: SrpServer | None = None @@ -1043,7 +1055,8 @@ def set_characteristics( """ queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS] - if HAP_REPR_PID in chars_query and not self._take_prepared_write( + timed_write = HAP_REPR_PID in chars_query + if timed_write and not self._take_prepared_write( chars_query[HAP_REPR_PID], client_addr ): # The spec requires a timed write that was never prepared, was @@ -1113,6 +1126,22 @@ def set_characteristics( } continue + if ( + value is not None + and not timed_write + and HAP_PERMISSION_TIMED_WRITE in char.properties[PROP_PERMISSIONS] + ): + if self.enforce_timed_writes: + results[aid][iid] = { + HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + } + continue + logger.debug( + "%s: %s requires a timed write but was written without one", + client_addr, + char.display_name, + ) + set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST set_result_value = None diff --git a/pyhap/const.py b/pyhap/const.py index 2f70f950..aa9468ab 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -56,6 +56,7 @@ HAP_PERMISSION_HIDDEN = "hd" HAP_PERMISSION_NOTIFY = "ev" HAP_PERMISSION_READ = "pr" +HAP_PERMISSION_TIMED_WRITE = "tw" HAP_PERMISSION_WRITE = "pw" HAP_PERMISSION_WRITE_RESPONSE = "wr" diff --git a/tests/test_timed_write.py b/tests/test_timed_write.py index a0f5c969..cac75f8b 100644 --- a/tests/test_timed_write.py +++ b/tests/test_timed_write.py @@ -1,6 +1,7 @@ """Tests for timed writes (prepare, then write with the pid).""" from collections.abc import Iterator +import logging from typing import Any, NamedTuple from unittest.mock import MagicMock, patch @@ -162,3 +163,80 @@ def test_prepared_pid_belongs_to_its_connection( HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST ) door.setter.assert_not_called() + + +def _rejection(char: WritableChar, status: int | None) -> dict[str, Any] | None: + """Return the expected write response, None for a plain success.""" + if status is None: + return None + return { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: char.aid, HAP_REPR_IID: char.iid, HAP_REPR_STATUS: status} + ] + } + + +def _highest_level(records: list[logging.LogRecord]) -> int: + """Return the highest log level in ``records``, 0 without any.""" + return max((record.levelno for record in records), default=0) + + +REJECTED = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + + +@pytest.mark.parametrize( + ("enforce", "name", "request_extra", "status", "calls", "logged"), + [ + (True, "HomeKitCameraActive", {}, REJECTED, 0, 0), + (True, "HomeKitCameraActive", {HAP_REPR_PID: 7}, None, 1, 0), + (True, "TargetDoorState", {}, None, 1, 0), + (False, "HomeKitCameraActive", {}, None, 1, 1), + (False, "HomeKitCameraActive", {HAP_REPR_PID: 7}, None, 1, 0), + (False, "TargetDoorState", {}, None, 1, 0), + ], + ids=[ + "enforced_plain_write", + "enforced_timed_write", + "enforced_plain_write_without_tw", + "lenient_plain_write", + "lenient_timed_write", + "lenient_plain_write_without_tw", + ], +) +def test_timed_write_permission( + driver: AccessoryDriver, + chars: dict[str, WritableChar], + caplog: pytest.LogCaptureFixture, + enforce: bool, + name: str, + request_extra: dict[str, Any], + status: int | None, + calls: int, + logged: int, +) -> None: + """Test a plain write to a timed write characteristic is refused or logged.""" + caplog.set_level(logging.DEBUG, logger="pyhap.accessory_driver") + char = chars[name] + driver.enforce_timed_writes = enforce + driver.prepare({HAP_REPR_TTL: 2500, HAP_REPR_PID: 7}, CLIENT) + query = { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: char.aid, HAP_REPR_IID: char.iid, HAP_REPR_VALUE: 0} + ], + **request_extra, + } + + response = driver.set_characteristics(query, CLIENT) + + assert response == _rejection(char, status) + assert char.setter.call_count == calls + assert caplog.text.count("requires a timed write") == logged + # Lenient mode must not warn, hubs write these values all the time. + assert _highest_level(caplog.records) < logging.WARNING + + +def test_enforce_timed_writes_defaults_to_off(driver: AccessoryDriver) -> None: + """Test timed writes are only enforced on request.""" + assert driver.enforce_timed_writes is False + enforced = AccessoryDriver(loop=driver.loop, enforce_timed_writes=True) + assert enforced.enforce_timed_writes is True From b1320b6e9d8e9d5ec3db783da388b6e4105a00ef Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:21:23 +0100 Subject: [PATCH 14/56] Fix tlv.encode for values of 510, 765, ... bytes The last fragment was sliced with data[-0:], which duplicated the whole value and produced undecodable output. Only emit a trailing fragment when bytes remain. --- pyhap/tlv.py | 5 ++++- tests/test_tlv.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pyhap/tlv.py b/pyhap/tlv.py index 068f11a4..076716f9 100644 --- a/pyhap/tlv.py +++ b/pyhap/tlv.py @@ -43,7 +43,10 @@ def encode(*args: Any, to_base64: bool = False) -> bytes | str: for y in range(0, total_length // 255): encoded = encoded + tag + b"\xff" + data[y * 255 : (y + 1) * 255] remaining = total_length % 255 - encoded = encoded + tag + struct.pack("B", remaining) + data[-remaining:] + if remaining: + encoded = ( + encoded + tag + struct.pack("B", remaining) + data[-remaining:] + ) pieces.append(encoded) diff --git a/tests/test_tlv.py b/tests/test_tlv.py index 86b53f31..a56bca0e 100644 --- a/tests/test_tlv.py +++ b/tests/test_tlv.py @@ -44,3 +44,36 @@ def test_tlv_decode_truncated_value() -> None: def test_tlv_decode_full_value() -> None: """Verify a value exactly as long as its length byte decodes.""" assert tlv.decode(b"\x01\x03ABC") == {b"\x01": b"ABC"} + + +def _fragments(value: bytes, sizes: list[int]) -> bytes: + """Build the expected TLV fragments of ``value`` with type 1.""" + encoded, offset = b"", 0 + for size in sizes: + encoded += b"\x01" + bytes([size]) + value[offset : offset + size] + offset += size + return encoded + + +@pytest.mark.parametrize( + ("length", "fragment_sizes"), + [ + (0, [0]), + (1, [1]), + (254, [254]), + (255, [255]), + (256, [255, 1]), + (509, [255, 254]), + (510, [255, 255]), + (511, [255, 255, 1]), + (765, [255, 255, 255]), + ], +) +def test_encode_long_values(length: int, fragment_sizes: list[int]) -> None: + """Test values are split in 255 byte fragments without empty trailing ones.""" + value = (bytes(range(251)) * 4)[:length] + + encoded = tlv.encode(b"\x01", value) + + assert encoded == _fragments(value, fragment_sizes) + assert tlv.decode(encoded) == {b"\x01": value} From 3082bbac7a7aa0f27ccef1191b750de0776c23bd Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:05:41 +0100 Subject: [PATCH 15/56] Await async characteristic setters before answering the write A setter that is a coroutine function was called but never awaited. With "r": true the coroutine was passed to the JSON encoder, which raised TypeError, so the whole write failed with a 500. Writes from a controller now wait for coroutine setters before they are answered, through the existing delayed response. Accessory and service setters still run first. A setter still running after the response timeout is cancelled and its characteristic answers -70408; the other characteristics keep their results. A setter that raises answers -70402, and a closed connection cancels the setters. The return value becomes the write response and the stored value under the same rules as for sync setters. The public set_characteristics() keeps its signature. Called directly, it schedules coroutine setters and answers at once. Only coroutines are awaited; a sync setter that returns a task or future works as before. --- pyhap/accessory_driver.py | 161 +++++++++++-- pyhap/characteristic.py | 6 +- pyhap/hap_handler.py | 21 +- tests/test_accessory_driver.py | 419 ++++++++++++++++++++++++++++++++- tests/test_hap_handler.py | 88 ++++++- tests/test_hap_protocol.py | 95 ++++++++ 6 files changed, 761 insertions(+), 29 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 7794081f..7a21457f 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -20,8 +20,10 @@ import asyncio import base64 from collections import defaultdict +from collections.abc import Awaitable, Coroutine from concurrent.futures import ThreadPoolExecutor import hashlib +import inspect import logging import os import re @@ -122,10 +124,24 @@ def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any return HAP_SERVER_STATUS.SUCCESS, response +def _close_unsupported_coroutine(result: Any, name: str) -> None: + """Close a coroutine an accessory or service setter returned. + + Only characteristic setters may be coroutine functions. + """ + if inspect.iscoroutine(result): + result.close() + logger.warning( + "%s: only characteristic setters may be coroutine functions", name + ) + + def _wrap_acc_setter(acc: Any, updates_by_service: Any, client_addr: Any) -> Any: """Process an accessory setter callback trapping and logging all exceptions.""" try: - acc.setter_callback(updates_by_service) + _close_unsupported_coroutine( + acc.setter_callback(updates_by_service), acc.display_name + ) except Exception: logger.exception( "%s: Error while setting characteristics to %s for the %s accessory", @@ -144,7 +160,9 @@ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any: # consumers of the data. service_chars = {char.display_name: value for char, value in chars.items()} try: - service.setter_callback(service_chars) + _close_unsupported_coroutine( + service.setter_callback(service_chars), service.display_name + ) except Exception: logger.exception( "%s: Error while setting characteristics to %s for the %s service", @@ -156,6 +174,11 @@ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any: return HAP_SERVER_STATUS.SUCCESS +# A characteristic write whose setter is a coroutine: the result entry to +# complete, the coroutine, whether a write response was asked for and the char. +_PendingWrite = tuple[dict[str, Any], Coroutine[Any, Any, Any], bool, Characteristic] + + def _set_characteristics_response( results: dict[int, dict[int, dict[str, Any]]], ) -> dict[str, Any] | None: @@ -1057,6 +1080,15 @@ def set_characteristics( ) -> dict[str, Any] | None: """Called from ``HAPServerHandler`` when iOS configures the characteristics. + Returns ``None`` when every write succeeded without a write response, + otherwise the per-characteristic results. + + A characteristic setter may be a coroutine function. Writes from a + controller wait for it before they are answered. Called directly, this + method only schedules the coroutine and does not wait for it. + Accessory and service setters must not be coroutine functions; a + coroutine they return is closed with a warning. + :param chars_query: A configuration query. For example: .. code-block:: python @@ -1073,6 +1105,52 @@ def set_characteristics( :type chars_query: dict """ + results, pending = self._apply_characteristic_writes(chars_query, client_addr) + for _, coro, _, _ in pending: + self.add_job(coro) + return _set_characteristics_response(results) + + def _set_characteristics_for_controller( + self, + chars_query: dict[str, Any], + client_addr: tuple[str, int], + timeout: float, + ) -> dict[str, Any] | None | Awaitable[dict[str, Any] | None]: + """Apply a controller's write, waiting for coroutine setters. + + Returns an awaitable of the response if a characteristic setter is a + coroutine function. Accessory and service setters run before those + coroutines start. A setter still running after ``timeout`` seconds is + cancelled and its characteristic answers -70408; cancelling the + awaitable, e.g. because the connection closed, cancels the setters. + """ + results, pending = self._apply_characteristic_writes(chars_query, client_addr) + if pending: + return self._async_finish_set_characteristics( + results, pending, client_addr, timeout + ) + return _set_characteristics_response(results) + + def _apply_characteristic_writes( + self, chars_query: dict[str, Any], client_addr: tuple[str, int] + ) -> tuple[dict[int, dict[int, dict[str, Any]]], list[_PendingWrite]]: + """Run the setters of a write and collect the per-characteristic results.""" + pending: list[_PendingWrite] = [] + try: + return self._run_characteristic_writes(chars_query, client_addr, pending) + except BaseException: + # A malformed later entry must not leave a setter coroutine + # un-awaited. + for _, coro, _, _ in pending: + coro.close() + raise + + def _run_characteristic_writes( + self, + chars_query: dict[str, Any], + client_addr: tuple[str, int], + pending: list[_PendingWrite], + ) -> tuple[dict[int, dict[int, dict[str, Any]]], list[_PendingWrite]]: queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS] timed_write = HAP_REPR_PID in chars_query @@ -1081,16 +1159,12 @@ def set_characteristics( ): # The spec requires a timed write that was never prepared, was # already used or has expired to be ignored as a whole. - return { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: query[HAP_REPR_AID], - HAP_REPR_IID: query[HAP_REPR_IID], - HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST, - } - for query in queries - ] - } + ignored: dict[int, dict[int, dict[str, Any]]] = defaultdict(dict) + for query in queries: + ignored[query[HAP_REPR_AID]][query[HAP_REPR_IID]] = { + HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + } + return ignored, [] not_notifiable = self._notify(queries, client_addr) @@ -1172,17 +1246,15 @@ def set_characteristics( # Only characteristics with the write response permission answer # a write with a value, and only when the controller asks for it. - if ( - set_result_value is not None - and query.get(HAP_REPR_WRITE_RESPONSE, False) + write_response = bool( + query.get(HAP_REPR_WRITE_RESPONSE, False) and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] - ): - result = { - HAP_REPR_STATUS: set_result, - HAP_REPR_VALUE: _store_write_response(char, set_result_value), - } - else: - result = {HAP_REPR_STATUS: set_result} + ) + result: dict[str, Any] = {HAP_REPR_STATUS: set_result} + if inspect.iscoroutine(set_result_value): + pending.append((result, set_result_value, write_response, char)) + elif set_result_value is not None and write_response: + result[HAP_REPR_VALUE] = _store_write_response(char, set_result_value) results[aid][iid] = result char_to_iid[char] = iid @@ -1214,6 +1286,51 @@ def set_characteristics( for char in chars: aid_results[char_to_iid[char]][HAP_REPR_STATUS] = set_result + return results, pending + + async def _async_finish_set_characteristics( + self, + results: dict[int, dict[int, dict[str, Any]]], + pending: list[_PendingWrite], + client_addr: tuple[str, int], + timeout: float, + ) -> dict[str, Any] | None: + """Wait for the coroutine setters, then build the response.""" + tasks = { + asyncio.ensure_future(coro): (result, write_response, char) + for result, coro, write_response, char in pending + } + try: + done, not_done = await asyncio.wait(tasks, timeout=timeout) + except asyncio.CancelledError: + for task in tasks: + task.cancel() + raise + for task in not_done: + task.cancel() + result, _, char = tasks[task] + logger.warning( + "%s: Timed out setting characteristic %s", + client_addr, + char.display_name, + ) + result[HAP_REPR_STATUS] = HAP_SERVER_STATUS.OPERATION_TIMED_OUT + for task in done: + result, write_response, char = tasks[task] + try: + outcome = task.result() + except (Exception, asyncio.CancelledError): + logger.exception( + "%s: Error while setting characteristic %s", + client_addr, + char.display_name, + ) + result[HAP_REPR_STATUS] = ( + HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE + ) + continue + if outcome is not None and write_response: + result[HAP_REPR_VALUE] = _store_write_response(char, outcome) return _set_characteristics_response(results) def _take_prepared_write(self, pid: Any, client_addr: tuple[str, int]) -> bool: diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index a0b09a69..4472638b 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -203,7 +203,8 @@ def __init__( # Setters may take just the value or (value, sender_client_addr); see # ``_setter_wants_addr``. The return value is sent back as the write # response when the characteristic has the "wr" permission and the - # controller asked for one. + # controller asked for one. A setter may also be a coroutine function; + # a controller's write is answered once it is done. self.setter_callback: Callable[..., Any] | None = None # Cache of whether ``setter_callback`` opts into the client address, # recomputed only when the setter object itself changes. @@ -398,7 +399,8 @@ def client_update_value( ) -> Any: """Called from broker for value change in Home app. - Change self.value to value and call callback. + Change self.value to value and call callback. Returns what the callback + returned, which the caller has to await if it is awaitable. """ original_value = value if not self._always_null or original_value is not None: diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index a30a388b..1dce8a06 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Awaitable from http import HTTPStatus +import inspect import logging import sys from typing import TYPE_CHECKING, Any, cast @@ -711,10 +712,19 @@ def handle_set_characteristics(self) -> None: "%s: Set characteristics content: %s", self.client_address, requested_chars ) - result = self.accessory_handler.set_characteristics( - requested_chars, cast("tuple[str, int]", self.client_address) + result = self.accessory_handler._set_characteristics_for_controller( + requested_chars, + cast("tuple[str, int]", self.client_address), + RESPONSE_TIMEOUT, ) assert self.response is not None # noqa: S101 + if inspect.isawaitable(result): + # A setter is still running; answer once it is done. The driver + # times out slow setters itself. + self.response.task = asyncio.ensure_future( + self._async_set_characteristics_body(self.response, result) + ) + return self._fill_set_characteristics_response(self.response, result) def _fill_set_characteristics_response( @@ -730,6 +740,13 @@ def _fill_set_characteristics_response( response.headers.append(("Content-Type", self.JSON_RESPONSE_TYPE)) response.body = to_hap_json(result) + async def _async_set_characteristics_body( + self, response: HAPResponse, result: Awaitable[dict[str, Any] | None] + ) -> bytes: + """Wait for the pending setters, then fill in the write response.""" + self._fill_set_characteristics_response(response, await result) + return response.body + def handle_prepare(self) -> None: """Handles a client request to prepare to write.""" if not self.is_encrypted: diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index a2975924..3c539a0a 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -2,6 +2,7 @@ import asyncio from concurrent.futures import ThreadPoolExecutor +import inspect import os import tempfile import time @@ -611,7 +612,7 @@ def test_listen_address(kwargs: dict[str, Any], expected: Any) -> None: def _write( driver: AccessoryDriver, acc: Accessory, char: Characteristic, **query: Any -) -> dict[str, Any] | None: +) -> Any: """Write 0 to ``char`` from a mock client with extra query fields.""" return driver.set_characteristics( { @@ -723,6 +724,422 @@ def test_write_response_value_of_write_only_char_not_readable( assert HAP_REPR_VALUE not in acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0] +ASYNC_TIMEOUT = 1.0 + + +async def _controller_write( + driver: AccessoryDriver, acc: Accessory, char: Characteristic, **query: Any +) -> dict[str, Any] | None: + """Write 0 to ``char`` like a controller and wait for the async setters.""" + result = driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 0, + **query, + } + ] + }, + ("1.2.3.4", 5), + ASYNC_TIMEOUT, + ) + assert inspect.isawaitable(result) + return await result + + +async def _async_return_one(value: int) -> int: + await asyncio.sleep(0) + return 1 + + +async def _async_return_one_with_addr(value: int, sender_client_addr: Any) -> int: + await asyncio.sleep(0) + return 1 + + +async def _async_return_none(value: int) -> None: + await asyncio.sleep(0) + + +async def _async_raise(value: int) -> NoReturn: + await asyncio.sleep(0) + raise ValueError("setter failed") + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize( + ("setter", "permissions", "expected", "stored"), + [ + pytest.param( + _async_return_one, + ["pr", "pw", "wr"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, HAP_REPR_VALUE: 1}, + 1, + id="write-response", + ), + pytest.param( + _async_return_one_with_addr, + ["pw", "wr"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, HAP_REPR_VALUE: 1}, + 1, + id="write-response-with-addr", + ), + pytest.param( + _async_raise, + ["pr", "pw", "wr"], + {HAP_REPR_STATUS: HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE}, + 0, + id="error", + ), + ], +) +async def test_async_setter_result( + driver: AccessoryDriver, + bridged: bool, + setter: Any, + permissions: list[str], + expected: dict[str, Any], + stored: int, +) -> None: + """The result of an async setter is awaited before the write is answered.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, bridged) + char.setter_callback = setter + + result = await _controller_write( + driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True} + ) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + **expected, + } + ] + } + assert char.value == stored + + +@pytest.mark.parametrize( + ("setter", "permissions", "query"), + [ + pytest.param(_async_return_one, ["pr", "pw", "wr"], {}, id="not-requested"), + pytest.param( + _async_return_one, + ["pr", "pw"], + {HAP_REPR_WRITE_RESPONSE: True}, + id="no-wr-permission", + ), + pytest.param( + _async_return_none, + ["pr", "pw", "wr"], + {HAP_REPR_WRITE_RESPONSE: True}, + id="no-value", + ), + ], +) +async def test_async_setter_without_write_response( + driver: AccessoryDriver, + setter: Any, + permissions: list[str], + query: dict[str, Any], +) -> None: + """A successful async setter without a write response leaves no body.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, False) + char.setter_callback = setter + + assert await _controller_write(driver, acc, char, **query) is None + assert char.value == 0 + + +async def _async_level_setter(values: Any) -> None: + raise AssertionError("must not run") + + +@pytest.mark.parametrize( + "attach", + [ + pytest.param( + lambda acc, service: setattr(acc, "setter_callback", _async_level_setter), + id="accessory", + ), + pytest.param( + lambda acc, service: setattr( + service, "setter_callback", _async_level_setter + ), + id="service", + ), + ], +) +async def test_async_accessory_or_service_setter_is_closed( + driver: AccessoryDriver, attach: Any, caplog: pytest.LogCaptureFixture +) -> None: + """A coroutine from an accessory or service setter is closed with a warning.""" + acc = Accessory(driver, "Test Accessory") + service = Service(uuid1(), "Test Service") + char = Characteristic( + "Value", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr", "pw"]} + ) + service.add_characteristic(char) + acc.add_service(service) + driver.add_accessory(acc) + attach(acc, service) + + result = driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 2, + } + ] + }, + ("1.2.3.4", 5), + ASYNC_TIMEOUT, + ) + + assert result is None + assert char.value == 2 + assert "only characteristic setters may be coroutine functions" in caplog.text + + +async def test_async_and_sync_setters_in_one_write(driver: AccessoryDriver) -> None: + """Sync and async setters of one request share a single response.""" + acc = Accessory(driver, "Test Accessory") + service = Service(uuid1(), "Test Service") + async_char = Characteristic( + "Async", + uuid1(), + {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr", "pw", "wr"]}, + ) + async_char.setter_callback = _async_return_one + sync_char = Characteristic( + "Sync", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr", "pw"]} + ) + sync_char.setter_callback = MagicMock(side_effect=ValueError) + service.add_characteristic(async_char, sync_char) + service_setter = MagicMock() + service.setter_callback = service_setter + acc.add_service(service) + driver.add_accessory(acc) + async_iid = acc.iid_manager.get_iid(async_char) + sync_iid = acc.iid_manager.get_iid(sync_char) + + result = driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: async_iid, + HAP_REPR_VALUE: 3, + HAP_REPR_WRITE_RESPONSE: True, + }, + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: sync_iid, HAP_REPR_VALUE: 4}, + ] + }, + ("1.2.3.4", 5), + ASYNC_TIMEOUT, + ) + + service_setter.assert_called_once_with({"Async": 3, "Sync": 4}) + assert inspect.isawaitable(result) + assert await result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: async_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: sync_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, + }, + ] + } + + +class _SlowSetter: + """An async setter that runs until it is cancelled.""" + + def __init__(self) -> None: + self.started = asyncio.Event() + self.cancelled = asyncio.Event() + + async def __call__(self, value: int) -> None: + self.started.set() + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + self.cancelled.set() + raise + + +class _RecordingSetter: + """An async setter that records that it ran and answers 1.""" + + def __init__(self) -> None: + self.ran = asyncio.Event() + + async def __call__(self, value: int) -> int: + self.ran.set() + return 1 + + +async def test_async_setter_timeout_keeps_other_results( + driver: AccessoryDriver, +) -> None: + """Only the setter that runs too long answers -70408, and it is cancelled.""" + acc = Accessory(driver, "Test Accessory") + service = Service(uuid1(), "Test Service") + slow = Characteristic( + "Slow", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pw"]} + ) + slow_setter = _SlowSetter() + slow.setter_callback = slow_setter + fast = Characteristic( + "Fast", uuid1(), {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pw", "wr"]} + ) + fast.setter_callback = _async_return_one + service.add_characteristic(slow, fast) + acc.add_service(service) + driver.add_accessory(acc) + slow_iid = acc.iid_manager.get_iid(slow) + fast_iid = acc.iid_manager.get_iid(fast) + + result = driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: slow_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: fast_iid, + HAP_REPR_VALUE: 2, + HAP_REPR_WRITE_RESPONSE: True, + }, + ] + }, + ("1.2.3.4", 5), + 0.05, + ) + + assert inspect.isawaitable(result) + assert await result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: slow_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.OPERATION_TIMED_OUT, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: fast_iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS, + HAP_REPR_VALUE: 1, + }, + ] + } + await asyncio.wait_for(slow_setter.cancelled.wait(), ASYNC_TIMEOUT) + + +async def test_cancelled_write_cancels_async_setters( + driver: AccessoryDriver, +) -> None: + """Cancelling the pending write, e.g. on a closed connection, stops setters.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pw"], False) + setter = _SlowSetter() + char.setter_callback = setter + task = asyncio.ensure_future(_controller_write(driver, acc, char)) + await setter.started.wait() + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.wait_for(setter.cancelled.wait(), ASYNC_TIMEOUT) + + +class _CoroutineRecorder: + """A setter that hands out a coroutine and keeps a reference to it.""" + + def __init__(self) -> None: + self.coro: Any = None + + def __call__(self, value: int) -> Any: + self.coro = _async_return_none(value) + return self.coro + + +async def test_malformed_entry_closes_pending_setters(driver: AccessoryDriver) -> None: + """A write that fails on a later entry closes the coroutines it created.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pw"], False) + setter = _CoroutineRecorder() + char.setter_callback = setter + + with pytest.raises(KeyError): + driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 0, + }, + {HAP_REPR_AID: acc.aid}, + ] + }, + ("1.2.3.4", 5), + ASYNC_TIMEOUT, + ) + + assert inspect.getcoroutinestate(setter.coro) == inspect.CORO_CLOSED + + +async def test_public_set_characteristics_schedules_async_setters( + driver: AccessoryDriver, +) -> None: + """Called directly, the write is answered at once and the setter runs later.""" + driver.loop = asyncio.get_running_loop() + acc, char, _ = _add_bridged_or_standalone(driver, ["pw", "wr"], False) + setter = _RecordingSetter() + char.setter_callback = setter + + assert _write(driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}) is None + await asyncio.wait_for(setter.ran.wait(), ASYNC_TIMEOUT) + + +async def test_setter_returning_a_task_is_not_awaited( + driver: AccessoryDriver, +) -> None: + """A sync setter that starts a task is answered without waiting for it.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pw"], False) + task = asyncio.ensure_future(asyncio.sleep(10)) + char.setter_callback = MagicMock(return_value=task) + + result = driver._set_characteristics_for_controller( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 0, + } + ] + }, + ("1.2.3.4", 5), + ASYNC_TIMEOUT, + ) + + assert result is None + assert not task.done() + task.cancel() + + def test_service_callbacks(driver: AccessoryDriver) -> None: bridge = Bridge(driver, "mybridge") acc = Accessory(driver, "TestAcc", aid=2) diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 9dc91435..a45e96ee 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -1,10 +1,11 @@ """Tests for the HAPServerHandler.""" +import asyncio import json from typing import Any, NoReturn from unittest.mock import patch from urllib.parse import urlparse -from uuid import UUID +from uuid import UUID, uuid1 from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 from cryptography.hazmat.primitives import serialization @@ -18,8 +19,9 @@ MAX_FAILED_PAIRING_ATTEMPTS, AccessoryDriver, ) -from pyhap.characteristic import CharacteristicError +from pyhap.characteristic import Characteristic, CharacteristicError from pyhap.const import HAP_PERMISSIONS, HAP_SERVER_STATUS +from pyhap.service import Service CLIENT_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1") CLIENT_UUID_BYTES = str(CLIENT_UUID).upper().encode("utf-8") @@ -1194,3 +1196,85 @@ def test_invalid_pairing_three(driver: AccessoryDriver) -> None: hap_handler.HAP_TLV_TAGS.SEQUENCE_NUM: hap_handler.HAP_TLV_STATES.M6, hap_handler.HAP_TLV_TAGS.ERROR_CODE: hap_handler.HAP_TLV_ERRORS.AUTHENTICATION, } + + +def _dispatch_write_to_async_setter( + driver: AccessoryDriver, setter: Any, body: bytes +) -> hap_handler.HAPResponse: + """Dispatch a characteristics write to a wr characteristic with ``setter``.""" + acc = Accessory(driver, "TestAcc") + service = Service(uuid1(), "Test Service") + char = Characteristic( + "Value", uuid1(), {"Format": "int", "Permissions": ["pr", "pw", "wr"]} + ) + char.setter_callback = setter + service.add_characteristic(char) + acc.add_service(service) + driver.add_accessory(acc) + assert acc.iid_manager.get_iid(char) == 9 + + handler = hap_handler.HAPServerHandler(driver, "peername") + handler.is_encrypted = True + request = h11.Request( + method="PUT", target="/characteristics", headers=[(b"host", b"t")] + ) + return handler.dispatch(request, body) + + +async def _async_return_one(_value: int) -> int: + await asyncio.sleep(0) + return 1 + + +async def _async_sleep(_value: int) -> None: + await asyncio.sleep(10) + + +@pytest.mark.parametrize( + ("body", "status_code", "headers", "response_body"), + [ + pytest.param( + b'{"characteristics":[{"aid":1,"iid":9,"value":0,"r":true}]}', + 207, + [("Content-Type", "application/hap+json")], + b'{"characteristics":[{"status":0,"value":1,"aid":1,"iid":9}]}', + id="write-response", + ), + pytest.param( + b'{"characteristics":[{"aid":1,"iid":9,"value":0}]}', + 204, + [], + b"", + id="no-write-response", + ), + ], +) +async def test_handle_set_characteristics_async_setter( + driver: AccessoryDriver, + body: bytes, + status_code: int, + headers: list[tuple[str, str]], + response_body: bytes, +) -> None: + """A write to an async setter is answered once the setter is done.""" + response = _dispatch_write_to_async_setter(driver, _async_return_one, body) + + assert response.task is not None + assert await response.task == response_body + assert response.status_code == status_code + assert response.headers == headers + + +async def test_handle_set_characteristics_async_setter_timeout( + driver: AccessoryDriver, +) -> None: + """An async setter that does not finish in time answers -70408 for its char.""" + with patch.object(hap_handler, "RESPONSE_TIMEOUT", 0.01): + response = _dispatch_write_to_async_setter( + driver, _async_sleep, b'{"characteristics":[{"aid":1,"iid":9,"value":0}]}' + ) + assert response.task is not None + assert await response.task == ( + b'{"characteristics":[{"status":-70408,"aid":1,"iid":9}]}' + ) + assert response.status_code == 207 diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 749e5b00..d1efbc4b 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -5,15 +5,18 @@ import time from typing import Any, NoReturn, cast from unittest.mock import MagicMock, Mock, patch +from uuid import uuid1 from cryptography.exceptions import InvalidTag from pyhap import hap_handler, hap_protocol from pyhap.accessory import Accessory, Bridge, get_topic from pyhap.accessory_driver import AccessoryDriver +from pyhap.characteristic import Characteristic from pyhap.const import HAP_REPR_AID, HAP_REPR_IID, HAP_REPR_VALUE from pyhap.hap_crypto import HAPCrypto, hap_hkdf from pyhap.hap_handler import HAPResponse +from pyhap.service import Service class MockTransport(asyncio.Transport): @@ -925,6 +928,98 @@ async def _async_get_snapshot(*_: Any) -> bytes: hap_proto.close() +ASYNC_PUT_REQUEST = ( + b"PUT /characteristics HTTP/1.1\r\n" + b"Host: HASS12\\032AD1C22._hap._tcp.local\r\n" + b"Content-Length: 58\r\n" + b"Content-Type: application/hap+json\r\n" + b"\r\n" + b'{"characteristics":[{"aid":1,"iid":9,"value":0,"r":true}]}' +) +ASYNC_PUT_RESPONSE = ( + b"HTTP/1.1 207 Multi-Status\r\n" + b"Content-Type: application/hap+json\r\n" + b"Content-Length: 60\r\n\r\n" + b'{"characteristics":[{"status":0,"value":1,"aid":1,"iid":9}]}' +) + + +def _async_setter_protocol( + driver: AccessoryDriver, setter_done: asyncio.Event +) -> hap_protocol.HAPServerProtocol: + """A connection to an accessory whose char 1.9 has an async setter. + + The setter waits for ``setter_done`` and answers 1 in the write response. + """ + transport = MagicMock() + transport.is_closing = Mock(return_value=False) + + async def _setter(_value: int) -> int: + await setter_done.wait() + return 1 + + acc = Accessory(driver, "TestAcc") + service = Service(uuid1(), "Test Service") + char = Characteristic( + "Value", uuid1(), {"Format": "int", "Permissions": ["pr", "pw", "wr"]} + ) + char.setter_callback = _setter + service.add_characteristic(char) + acc.add_service(service) + driver.add_accessory(acc) + assert acc.iid_manager.get_iid(char) == 9 + + hap_proto = hap_protocol.HAPServerProtocol(MagicMock(), {}, driver) + hap_proto.connection_made(transport) + _enable_encryption(hap_proto) + return hap_proto + + +async def test_set_characteristics_async_setter_write_response( + driver: AccessoryDriver, +) -> None: + """The write response of an async setter is sent once the setter is done.""" + setter_done = asyncio.Event() + hap_proto = _async_setter_protocol(driver, setter_done) + + with patch.object(hap_proto.transport, "writelines") as writelines: + hap_proto.data_received(ASYNC_PUT_REQUEST) + writelines.assert_not_called() + setter_done.set() + await _await_response(hap_proto) + + hap_proto.close() + assert b"".join(writelines.call_args_list[0][0]) == ASYNC_PUT_RESPONSE + + +async def test_get_pipelined_behind_async_setter(driver: AccessoryDriver) -> None: + """A GET behind an async write is answered after the 207, with the new value.""" + setter_done = asyncio.Event() + hap_proto = _async_setter_protocol(driver, setter_done) + + with patch.object(hap_proto.transport, "writelines") as writelines: + hap_proto.data_received( + ASYNC_PUT_REQUEST + + b"GET /characteristics?id=1.9 HTTP/1.1\r\n" + + b"Host: HASS12\\032AD1C22._hap._tcp.local\r\n\r\n" + ) + await asyncio.sleep(0) + writelines.assert_not_called() + setter_done.set() + await _await_response(hap_proto) + await asyncio.sleep(0) + + hap_proto.close() + assert writelines.call_count == 2 + assert b"".join(writelines.call_args_list[0][0]) == ASYNC_PUT_RESPONSE + assert b"".join(writelines.call_args_list[1][0]) == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/hap+json\r\n" + b"Content-Length: 49\r\n\r\n" + b'{"characteristics":[{"aid":1,"iid":9,"value":1}]}' + ) + + def test_upgrade_to_encrypted(driver: AccessoryDriver) -> None: """Test we switch to encrypted wen we get a shared_key.""" loop = MagicMock() From f946d1f3fa32750bb4857450aac4278ce63d48a7 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:58:12 +0100 Subject: [PATCH 16/56] Only let the writing connection read back a write response A write response of a characteristic that also has pr was stored as its value, so any paired controller could read it with GET /characteristics or /accessories. Keep it per connection instead: the connection that wrote reads its last response until it closes. Other connections read the characteristic's own value, which the write leaves as it was, also when the setter raises or returns nothing. Any new write of a connection drops its former response. Characteristics without pr keep the response as the value. --- pyhap/accessory_driver.py | 106 +++++++++++--- pyhap/hap_handler.py | 2 +- tests/test_accessory_driver.py | 257 +++++++++++++++++++++++++++++++-- tests/test_hap_handler.py | 56 ++++++- 4 files changed, 385 insertions(+), 36 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index bd05a8c2..88379cbe 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -88,22 +88,12 @@ MAX_PREPARED_WRITE_TTL_SECONDS = 30.0 -def _store_write_response(char: Characteristic, response: Any) -> Any: - """Keep the write response as the value and return what to send. - - The written value of a write response characteristic, such as an SDP - offer, is a request. What the accessory answered is its state. - """ +def _stored_value(char: Any, value: Any) -> Any: + """Return the value ``client_update_value`` stores for ``value``.""" try: - char.value = char.to_valid_value(response) + return char.to_valid_value(value) except ValueError: - logger.debug( - "%s: keeping the written value, the write response is invalid: %s", - char.display_name, - response, - ) - return response - return char.value + return value def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]: @@ -356,6 +346,8 @@ def __init__( network_tuple = (self._listen_address, self.state.port) self.http_server = HAPServer(network_tuple, self) self.prepared_writes = {} + # Write responses of readable characteristics, per client. + self._write_responses: dict[tuple[str, int], dict[Characteristic, Any]] = {} def listen_address(self) -> str | list[str]: """Return the address, or list of addresses, the HAP server listens on.""" @@ -602,6 +594,8 @@ def connection_lost(self, client: Any) -> None: for topic in client_topics: self.async_subscribe_client_topic(client, topic, subscribe=False) self.prepared_writes.pop(client, None) + # Deferred, so a write response that is already on its way is dropped too. + self.loop.call_soon(self._write_responses.pop, client, None) def publish( self, @@ -926,12 +920,18 @@ def get_accessories(self, include_value: bool = True) -> dict[str, Any]: logger.debug("Get accessories response: %s", hap_rep) return {HAP_REPR_ACCS: hap_rep} - def get_characteristics(self, char_ids: Any) -> dict[str, Any]: + def get_characteristics( + self, char_ids: Any, client_addr: tuple[str, int] | None = None + ) -> dict[str, Any]: """Returns values for the required characteristics. :param char_ids: A list of characteristic "paths", e.g. "1.2" is aid 1, iid 2. :type char_ids: list + :param client_addr: The client that reads. It reads back its own write + responses. + :type client_addr: tuple + :return: Status success for each required characteristic. For example: .. code-block:: python @@ -947,6 +947,9 @@ def get_characteristics(self, char_ids: Any) -> dict[str, Any]: :rtype: dict """ assert self.accessory is not None # noqa: S101 + write_responses = ( + self._write_responses.get(client_addr, {}) if client_addr else {} + ) chars = [] for aid_iid in char_ids: aid, iid = (int(i) for i in aid_iid.split(".")) @@ -984,7 +987,11 @@ def get_characteristics(self, char_ids: Any) -> dict[str, Any]: HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC ) elif available: - rep[HAP_REPR_VALUE] = char.get_value() + rep[HAP_REPR_VALUE] = ( + write_responses[char] + if char in write_responses + else char.get_value() + ) rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS except CharacteristicError: logger.error( @@ -1103,6 +1110,24 @@ def set_characteristics( } continue + # Only characteristics with the write response permission answer + # a write with a value, and only when the controller asks for it. + write_response = bool( + query.get(HAP_REPR_WRITE_RESPONSE, False) + and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] + ) + # A new write replaces a former response of this connection. The + # request of a readable write response characteristic is not + # shared, whatever the setter does, except to ev subscribers. + if value is not None: + self._write_responses.get(client_addr, {}).pop(char, None) + private_write = ( + write_response + and value is not None + and HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] + ) + previous_value = char.value + set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST set_result_value = None @@ -1110,17 +1135,16 @@ def set_characteristics( set_result, set_result_value = _wrap_char_setter( char, value, client_addr ) + # Put back the former value unless the setter set its own. + if private_write and char.value == _stored_value(char, value): + char.value = previous_value - # Only characteristics with the write response permission answer - # a write with a value, and only when the controller asks for it. - if ( - set_result_value is not None - and query.get(HAP_REPR_WRITE_RESPONSE, False) - and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] - ): + if set_result_value is not None and write_response: result = { HAP_REPR_STATUS: set_result, - HAP_REPR_VALUE: _store_write_response(char, set_result_value), + HAP_REPR_VALUE: self._store_write_response( + char, set_result_value, client_addr + ), } else: result = {HAP_REPR_STATUS: set_result} @@ -1170,6 +1194,40 @@ def set_characteristics( return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None + def _store_write_response( + self, + char: Characteristic, + response: Any, + client_addr: tuple[str, int] | None, + ) -> Any: + """Keep the write response and return what to send. + + The written value of a write response characteristic, such as an SDP + offer, is a request. What the accessory answered is its state. If the + characteristic is readable, only the connection that wrote reads the + response back, until it closes, and it wins over the getter and a later + ``set_value``. Other connections read the characteristic's own value, + which the write leaves as it was. A write without ``r`` stays readable + for everyone, and a characteristic that also has ``ev`` notifies the + written value as any write does. + """ + readable = HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] + try: + response = char.to_valid_value(response) + except ValueError: + logger.debug( + "%s: not keeping the write response, it is invalid: %s", + char.display_name, + response, + ) + return response + if readable: + if client_addr is not None: + self._write_responses.setdefault(client_addr, {})[char] = response + else: + char.value = response + return response + def prepare( self, prepare_query: dict[str, Any], client_addr: tuple[str, int] ) -> dict[str, int]: diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 83a47380..e027811a 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -679,7 +679,7 @@ def handle_get_characteristics(self) -> None: assert self.parsed_url is not None # noqa: S101 params = parse_qs(self.parsed_url.query) response = self.accessory_handler.get_characteristics( - params["id"][0].split(",") + params["id"][0].split(","), cast("tuple[str, int]", self.client_address) ) chars = response[HAP_REPR_CHARS] diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 475a3c7f..bc85ddb2 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -2,6 +2,7 @@ import asyncio from concurrent.futures import ThreadPoolExecutor +from functools import partial import os import tempfile import time @@ -609,6 +610,10 @@ def test_listen_address(kwargs: dict[str, Any], expected: Any) -> None: assert server.call_args.args[0] == (expected, 51234) +WRITER = ("1.2.3.4", 5) +OTHER_CONTROLLER = ("5.6.7.8", 9) + + def _write( driver: AccessoryDriver, acc: Accessory, char: Characteristic, **query: Any ) -> dict[str, Any] | None: @@ -624,17 +629,25 @@ def _write( } ] }, - ("1.2.3.4", 5), + WRITER, ) @pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) -@pytest.mark.parametrize("permissions", [["pr", "pw", "wr"], ["pw", "wr"]]) +@pytest.mark.parametrize( + ("permissions", "stored"), + [ + # A readable char keeps its default value, see the read tests below. + pytest.param(["pr", "pw", "wr"], 0, id="readable"), + pytest.param(["pw", "wr"], 1, id="write-only"), + ], +) @pytest.mark.parametrize(("returned", "sent"), [(1, 1), (1.7, 1)]) def test_write_response_returned_when_requested( driver: AccessoryDriver, bridged: bool, permissions: list[str], + stored: int, returned: Any, sent: int, ) -> None: @@ -659,8 +672,7 @@ def test_write_response_returned_when_requested( } char.setter_callback.assert_called_once_with(0) service_setter.assert_called_once_with({"Value": 0}) - # Like HAP-NodeJS, the response is the value from now on. - assert char.value == sent + assert char.value == stored @pytest.mark.parametrize( @@ -688,16 +700,27 @@ def test_write_response_not_returned( assert char.value == 0 -def test_invalid_write_response_keeps_written_value(driver: AccessoryDriver) -> None: - """A response the char can't hold is still sent, the written value stays.""" - acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], False) +@pytest.mark.parametrize( + ("permissions", "stored"), + [ + pytest.param(["pr", "pw", "wr"], 0, id="readable"), + pytest.param(["pw", "wr"], 5, id="write-only"), + ], +) +def test_invalid_write_response_is_sent_but_not_kept( + driver: AccessoryDriver, permissions: list[str], stored: int +) -> None: + """A response the char can't hold is still sent, but not kept as its value.""" + acc, char, _ = _add_bridged_or_standalone(driver, permissions, False) char.setter_callback = MagicMock(return_value="not a number") - response = _write(driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}) + response = _write( + driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True} + ) assert response is not None assert response[HAP_REPR_CHARS][0][HAP_REPR_VALUE] == "not a number" - assert char.value == 0 + assert char.value == stored def test_write_response_value_of_write_only_char_not_readable( @@ -723,6 +746,222 @@ def test_write_response_value_of_write_only_char_not_readable( assert HAP_REPR_VALUE not in acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0] +def _add_write_response_char( + driver: AccessoryDriver, bridged: bool +) -> tuple[Accessory, Characteristic, str]: + """Add a pr, pw and wr char that answers a write with 7.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], bridged) + char.getter_callback = None + char.setter_callback = MagicMock(return_value=7) + return acc, char, f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + + +def _read(driver: AccessoryDriver, char_id: str, client_addr: Any) -> Any: + """Return the value ``client_addr`` reads from ``char_id``.""" + (rep,) = driver.get_characteristics([char_id], client_addr)[HAP_REPR_CHARS] + assert rep[HAP_REPR_STATUS] == HAP_SERVER_STATUS.SUCCESS + return rep[HAP_REPR_VALUE] + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +@pytest.mark.parametrize( + ("reader", "expected"), + [ + pytest.param(WRITER, 7, id="writer"), + pytest.param(OTHER_CONTROLLER, 0, id="other-controller"), + pytest.param(None, 0, id="no-client"), + ], +) +def test_write_response_readable_only_by_writer( + driver: AccessoryDriver, bridged: bool, reader: Any, expected: int +) -> None: + """Only the controller that wrote reads its write response back.""" + acc, char, char_id = _add_write_response_char(driver, bridged) + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + assert _read(driver, char_id, reader) == expected + + +def test_write_responses_kept_per_controller(driver: AccessoryDriver) -> None: + """Each controller reads its own last write response.""" + acc, char, char_id = _add_write_response_char(driver, False) + char.setter_callback = MagicMock(side_effect=[7, 8]) + request = { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 5, + HAP_REPR_WRITE_RESPONSE: True, + } + ] + } + + driver.set_characteristics(request, WRITER) + driver.set_characteristics(request, OTHER_CONTROLLER) + + assert _read(driver, char_id, WRITER) == 7 + assert _read(driver, char_id, OTHER_CONTROLLER) == 8 + + +@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"]) +def test_write_response_not_in_accessories( + driver: AccessoryDriver, bridged: bool +) -> None: + """The accessory database has the default value, not the write response.""" + acc, char, _ = _add_write_response_char(driver, bridged) + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + acc_rep = driver.get_accessories()[HAP_REPR_ACCS][-1] + assert acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 0 + + +async def test_write_response_dropped_on_disconnect(driver: AccessoryDriver) -> None: + """A write response is not read back after the connection closed.""" + driver.loop = asyncio.get_running_loop() + acc, char, char_id = _add_write_response_char(driver, False) + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + driver.connection_lost(WRITER) + assert _read(driver, char_id, WRITER) == 7 + # The response is dropped once the loop runs. + await asyncio.sleep(0) + + assert _read(driver, char_id, WRITER) == 0 + + +def test_plain_write_replaces_write_response(driver: AccessoryDriver) -> None: + """A write without ``r`` drops the writer's former response.""" + acc, char, char_id = _add_write_response_char(driver, False) + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + _write(driver, acc, char, **{HAP_REPR_VALUE: 9}) + + assert _read(driver, char_id, WRITER) == 9 + assert _read(driver, char_id, OTHER_CONTROLLER) == 9 + + +@pytest.mark.parametrize( + ("setter_kwargs", "writer_reads"), + [ + pytest.param({"return_value": 7}, 7, id="answered"), + pytest.param({"side_effect": RuntimeError}, 3, id="raises"), + ], +) +def test_write_response_keeps_accessory_value( + driver: AccessoryDriver, setter_kwargs: dict[str, Any], writer_reads: int +) -> None: + """Other connections keep reading the value the accessory set.""" + acc, char, char_id = _add_write_response_char(driver, False) + char.setter_callback = MagicMock(**setter_kwargs) + char.set_value(3) + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + assert _read(driver, char_id, WRITER) == writer_reads + assert _read(driver, char_id, OTHER_CONTROLLER) == 3 + + +def test_write_response_keeps_value_set_by_setter(driver: AccessoryDriver) -> None: + """A value the setter sets on its own characteristic is kept.""" + acc, char, char_id = _add_write_response_char(driver, False) + + def _setter(value: int) -> int: + char.set_value(42) + return 7 + + char.setter_callback = _setter + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + assert _read(driver, char_id, WRITER) == 7 + assert _read(driver, char_id, OTHER_CONTROLLER) == 42 + + +@pytest.mark.parametrize("reader", [WRITER, OTHER_CONTROLLER], ids=["writer", "other"]) +@pytest.mark.parametrize( + "make_setter", + [ + pytest.param(partial(MagicMock, side_effect=RuntimeError), id="raises"), + pytest.param(partial(MagicMock, return_value=None), id="no-response"), + pytest.param(lambda: None, id="no-setter"), + ], +) +def test_write_response_request_not_shared_without_response( + driver: AccessoryDriver, reader: Any, make_setter: Any +) -> None: + """The written request stays private when no response is kept.""" + acc, char, char_id = _add_write_response_char(driver, False) + char.setter_callback = make_setter() + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) + + assert _read(driver, char_id, reader) == 0 + + +@pytest.mark.parametrize( + "second", + [ + pytest.param(RuntimeError("boom"), id="raises"), + pytest.param("not a number", id="invalid"), + pytest.param(None, id="no-response"), + ], +) +def test_stale_write_response_dropped(driver: AccessoryDriver, second: Any) -> None: + """A later write that keeps no response drops the former one.""" + acc, char, char_id = _add_write_response_char(driver, False) + char.setter_callback = MagicMock(side_effect=[7, second]) + query = {HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True} + + _write(driver, acc, char, **query) + _write(driver, acc, char, **query) + + assert _read(driver, char_id, WRITER) == 0 + + +def test_write_response_without_client_not_kept(driver: AccessoryDriver) -> None: + """A write response without a known connection is sent but not kept.""" + acc, char, _ = _add_write_response_char(driver, False) + request = { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: acc.iid_manager.get_iid(char), + HAP_REPR_VALUE: 5, + HAP_REPR_WRITE_RESPONSE: True, + } + ] + } + + no_client: Any = None + response = driver.set_characteristics(request, no_client) + + assert response is not None + assert response[HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 7 + assert driver._write_responses == {} + + +@pytest.mark.parametrize("reader", [WRITER, OTHER_CONTROLLER], ids=["writer", "other"]) +@pytest.mark.parametrize( + "query", + [ + pytest.param({}, id="not-requested"), + pytest.param({HAP_REPR_WRITE_RESPONSE: False}, id="declined"), + ], +) +def test_written_value_readable_without_write_response( + driver: AccessoryDriver, reader: Any, query: dict[str, Any] +) -> None: + """Without a write response the written value is read by every controller.""" + acc, char, char_id = _add_write_response_char(driver, False) + + _write(driver, acc, char, **{HAP_REPR_VALUE: 5, **query}) + + assert _read(driver, char_id, reader) == 5 + + def test_service_callbacks(driver: AccessoryDriver) -> None: bridge = Bridge(driver, "mybridge") acc = Accessory(driver, "TestAcc", aid=2) diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 9dc91435..fc390c95 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -4,7 +4,7 @@ from typing import Any, NoReturn from unittest.mock import patch from urllib.parse import urlparse -from uuid import UUID +from uuid import UUID, uuid1 from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 from cryptography.hazmat.primitives import serialization @@ -18,8 +18,15 @@ MAX_FAILED_PAIRING_ATTEMPTS, AccessoryDriver, ) -from pyhap.characteristic import CharacteristicError +from pyhap.characteristic import ( + HAP_FORMAT_INT, + PROP_FORMAT, + PROP_PERMISSIONS, + Characteristic, + CharacteristicError, +) from pyhap.const import HAP_PERMISSIONS, HAP_SERVER_STATUS +from pyhap.service import Service CLIENT_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1") CLIENT_UUID_BYTES = str(CLIENT_UUID).upper().encode("utf-8") @@ -1018,6 +1025,51 @@ def test_handle_get_characteristics_encrypted(driver: AccessoryDriver) -> None: assert decoded_response["characteristics"][0]["status"] == -70402 +@pytest.mark.parametrize( + ("reader", "expected"), + [ + pytest.param(("1.2.3.4", 5), b'"value":7', id="writer"), + pytest.param(("5.6.7.8", 9), b'"value":0', id="other-controller"), + ], +) +def test_handle_get_characteristics_write_response_per_client( + driver: AccessoryDriver, reader: tuple[str, int], expected: bytes +) -> None: + """Only the client that wrote reads the write response back.""" + acc = Accessory(driver, "TestAcc", aid=1) + service = Service(uuid1(), "Test Service") + char = Characteristic( + "Value", + uuid1(), + {PROP_FORMAT: HAP_FORMAT_INT, PROP_PERMISSIONS: ["pr", "pw", "wr"]}, + ) + char.setter_callback = lambda _value: 7 + service.add_characteristic(char) + acc.add_service(service) + driver.add_accessory(acc) + iid = acc.iid_manager.get_iid(char) + + writer = hap_handler.HAPServerHandler(driver, ("1.2.3.4", 5)) + writer.is_encrypted = True + writer.response = hap_handler.HAPResponse() + writer.request_body = ( + b'{"characteristics":[{"aid":1,"iid":%d,"value":5,"r":true}]}' % iid + ) + writer.handle_set_characteristics() + assert b'"value":7' in writer.response.body + + handler = hap_handler.HAPServerHandler(driver, reader) + handler.is_encrypted = True + response = hap_handler.HAPResponse() + handler.response = response + handler.path = f"/characteristics?id=1.{iid}" + handler.parsed_url = urlparse(handler.path) + handler.handle_get_characteristics() + + assert response.status_code == 200 + assert expected in response.body + + def test_handle_resource_unencrypted_rejected(driver: AccessoryDriver) -> None: """Verify an unencrypted POST /resource is rejected with 401. From c4c842b2a303bf245f25c257256a522829bfbf33 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:15:14 +0100 Subject: [PATCH 17/56] Test write response privacy with async setters The writing connection reads an async setter's response back, other connections don't, the request stays private while the setter runs, and a response that completes as the connection closes is not kept. --- tests/test_accessory_driver.py | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 8c44f35e..62cafbd4 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -846,6 +846,89 @@ async def test_async_setter_result( assert char.value == stored +@pytest.mark.parametrize( + ("reader", "expected"), + [ + pytest.param(WRITER, 1, id="writer"), + pytest.param(OTHER_CONTROLLER, 0, id="other-controller"), + ], +) +async def test_async_write_response_readable_only_by_writer( + driver: AccessoryDriver, reader: tuple[str, int], expected: int +) -> None: + """Only the controller that wrote reads an async setter's response back.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], False) + char.getter_callback = None + char.setter_callback = _async_return_one + + await _controller_write( + driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True} + ) + + char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + assert _read(driver, char_id, reader) == expected + + +async def test_async_write_response_dropped_when_closed_while_queued( + driver: AccessoryDriver, +) -> None: + """A response that completes as the connection closes is not kept.""" + # This covers a completion that is already queued when the connection + # closes. The cancel in ``HAPServerProtocol.close()`` covers the rest. + driver.loop = asyncio.get_running_loop() + acc, char, _ = _add_write_response_char(driver, False) + release = asyncio.Event() + + async def _setter(value: int) -> int: + await release.wait() + return 7 + + char.setter_callback = _setter + write = asyncio.ensure_future( + _controller_write( + driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True} + ) + ) + await asyncio.sleep(0) + release.set() + # Two loop steps: the setter is done and the write's completion is queued. + await asyncio.sleep(0) + await asyncio.sleep(0) + + driver.connection_lost(WRITER) + await write + await asyncio.sleep(0) + + assert driver._write_responses == {} + + +async def test_async_write_response_request_not_shared( + driver: AccessoryDriver, +) -> None: + """Other controllers don't read the written request while the setter runs.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], False) + char.getter_callback = None + release = asyncio.Event() + + async def _setter(value: int) -> int: + await release.wait() + return 1 + + char.setter_callback = _setter + write = asyncio.ensure_future( + _controller_write( + driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True} + ) + ) + await asyncio.sleep(0) + + char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + assert _read(driver, char_id, OTHER_CONTROLLER) == 0 + release.set() + await write + assert _read(driver, char_id, OTHER_CONTROLLER) == 0 + + @pytest.mark.parametrize( ("setter", "permissions", "query"), [ From 8f298c6b8b7149e7d7a4a15baad7c7f8fcaf18ec Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:31:11 +0100 Subject: [PATCH 18/56] Drop the redundant bool() around the write response check --- pyhap/accessory_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 88379cbe..bef8e269 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1112,7 +1112,7 @@ def set_characteristics( # Only characteristics with the write response permission answer # a write with a value, and only when the controller asks for it. - write_response = bool( + write_response = ( query.get(HAP_REPR_WRITE_RESPONSE, False) and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] ) From ff12fb69d8669d2f7e901f93161e1c5808147699 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:32:46 +0100 Subject: [PATCH 19/56] Type the write response helpers with the HAP JSON value types --- pyhap/accessory_driver.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index bef8e269..04f57d7b 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -87,13 +87,17 @@ MAX_PREPARED_WRITES_PER_CLIENT = 16 MAX_PREPARED_WRITE_TTL_SECONDS = 30.0 +# A characteristic value as HAP JSON carries it. +HAPValue = bool | int | float | str | None -def _stored_value(char: Any, value: Any) -> Any: + +def _stored_value(char: Characteristic, value: HAPValue) -> HAPValue: """Return the value ``client_update_value`` stores for ``value``.""" try: - return char.to_valid_value(value) + stored: HAPValue = char.to_valid_value(value) except ValueError: return value + return stored def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]: @@ -347,7 +351,9 @@ def __init__( self.http_server = HAPServer(network_tuple, self) self.prepared_writes = {} # Write responses of readable characteristics, per client. - self._write_responses: dict[tuple[str, int], dict[Characteristic, Any]] = {} + self._write_responses: dict[ + tuple[str, int], dict[Characteristic, HAPValue] + ] = {} def listen_address(self) -> str | list[str]: """Return the address, or list of addresses, the HAP server listens on.""" @@ -1197,9 +1203,9 @@ def set_characteristics( def _store_write_response( self, char: Characteristic, - response: Any, + response: HAPValue, client_addr: tuple[str, int] | None, - ) -> Any: + ) -> HAPValue: """Keep the write response and return what to send. The written value of a write response characteristic, such as an SDP @@ -1213,7 +1219,7 @@ def _store_write_response( """ readable = HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] try: - response = char.to_valid_value(response) + valid: HAPValue = char.to_valid_value(response) except ValueError: logger.debug( "%s: not keeping the write response, it is invalid: %s", @@ -1223,10 +1229,10 @@ def _store_write_response( return response if readable: if client_addr is not None: - self._write_responses.setdefault(client_addr, {})[char] = response + self._write_responses.setdefault(client_addr, {})[char] = valid else: - char.value = response - return response + char.value = valid + return valid def prepare( self, prepare_query: dict[str, Any], client_addr: tuple[str, int] From c9ed715b687bea4ed9e1f527f1098bdf739603d4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:32:51 +0100 Subject: [PATCH 20/56] Explain why a private write puts the former value back --- pyhap/accessory_driver.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 04f57d7b..387ba419 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1141,7 +1141,11 @@ def set_characteristics( set_result, set_result_value = _wrap_char_setter( char, value, client_addr ) - # Put back the former value unless the setter set its own. + # A write with ``r`` to a readable characteristic is a request, + # e.g. a Setup Endpoints offer with the writer's SRTP keys. Only + # the writer reads the answer back, see _store_write_response. + # client_update_value stored the request as the value, so put + # back the former one unless the setter set a value of its own. if private_write and char.value == _stored_value(char, value): char.value = previous_value From 2964a7bdad99c300b5cb5fb8cf14862371d5b879 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:57:24 +0100 Subject: [PATCH 21/56] Require Python 3.11 Python 3.10 reached its end of life in October 2026. asyncio.timeout replaces the async_timeout backport. --- .github/workflows/ci.yaml | 2 +- benchmarks/run.py | 4 +--- pyhap/camera.py | 10 ++-------- pyhap/camera_talkback.py | 5 +---- pyhap/hap_handler.py | 8 +------- pyhap/hds_server.py | 2 +- pyhap/util.py | 10 ++-------- pyproject.toml | 8 +++----- tests/test_hap_protocol.py | 2 +- tox.ini | 6 +----- 10 files changed, 14 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ef2a7b5..11e0b290 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/benchmarks/run.py b/benchmarks/run.py index 8ffc0053..be72664a 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -754,9 +754,7 @@ def build_meta(args: argparse.Namespace) -> dict[str, Any]: """Metadata describing the code and machine the run was made on.""" return { "benchmark_version": 1, - "timestamp": datetime_module.datetime.now( - datetime_module.timezone.utc - ).isoformat(), + "timestamp": datetime_module.datetime.now(datetime_module.UTC).isoformat(), "commit": git_commit(), "label": args.label, "python": sys.version.split()[0], diff --git a/pyhap/camera.py b/pyhap/camera.py index 4caccd4e..f811f438 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -24,7 +24,6 @@ import logging import os import struct -import sys from typing import Any from uuid import UUID @@ -36,11 +35,6 @@ from pyhap.service import Service from pyhap.util import base64_to_bytes, byte_bool, callback, to_base64_str -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - SETUP_TYPES = { "SESSION_ID": b"\x01", "STATUS": b"\x02", @@ -1598,10 +1592,10 @@ async def stop_stream(self, session_info: dict[str, Any]) -> None: logger.info("[%s] Stopping stream.", session_id) try: ffmpeg_process.terminate() - async with async_timeout(2.0): + async with asyncio.timeout(2.0): _, stderr = await ffmpeg_process.communicate() logger.debug("Stream command stderr: %s", stderr) - except asyncio.TimeoutError: + except TimeoutError: logger.error( "Timeout while waiting for the stream process " "to terminate. Trying with kill." diff --git a/pyhap/camera_talkback.py b/pyhap/camera_talkback.py index a6c62310..fcfec43c 100644 --- a/pyhap/camera_talkback.py +++ b/pyhap/camera_talkback.py @@ -199,10 +199,7 @@ def _run(self) -> None: try: data, addr = self._sock.recvfrom(2048) except TimeoutError: - # socket.timeout aliases TimeoutError from Python 3.10 on, but - # is an OSError subclass on 3.9, which this project supports, - # so catching TimeoutError here would kill the receiver thread - # on the first (0.5s) poll timeout on Python 3.9. + # The 0.5 s poll timeout, check the stop event again. continue except OSError: break diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 83a47380..5ada36db 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -7,7 +7,6 @@ from collections.abc import Awaitable from http import HTTPStatus import logging -import sys from typing import TYPE_CHECKING, Any, cast from urllib.parse import ParseResult, parse_qs, urlparse import uuid @@ -32,11 +31,6 @@ from .state import State from .util import from_hap_json, to_hap_json -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - if TYPE_CHECKING: from .accessory_driver import AccessoryDriver @@ -104,7 +98,7 @@ class UnprivilegedRequestException(Exception): async def _run_with_timeout(coro: Awaitable[bytes], timeout: float) -> bytes: """Run a coroutine with a timeout.""" - async with async_timeout(timeout): + async with asyncio.timeout(timeout): return await coro diff --git a/pyhap/hds_server.py b/pyhap/hds_server.py index c059dd8c..785ee888 100644 --- a/pyhap/hds_server.py +++ b/pyhap/hds_server.py @@ -174,7 +174,7 @@ async def drain(self) -> None: """ try: await asyncio.wait_for(self._can_write.wait(), _DRAIN_TIMEOUT) - except asyncio.TimeoutError: + except TimeoutError: logger.info("Aborting HDS connection: peer stopped reading") self.abort() # connection_lost wakes us and cancels the recording streams, so a diff --git a/pyhap/util.py b/pyhap/util.py index ff1200fd..d540ced9 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -5,7 +5,6 @@ import inspect import random import socket -import sys from typing import Any, cast from uuid import UUID @@ -13,11 +12,6 @@ from .const import BASE_UUID -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" HEX_DIGITS = "0123456789ABCDEF" _BACKGROUND_TASKS: set[asyncio.Task[Any]] = set() @@ -147,9 +141,9 @@ async def event_wait(event: asyncio.Event, timeout: float) -> bool: :rtype: bool """ try: - async with async_timeout(timeout): + async with asyncio.timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except TimeoutError: pass return event.is_set() diff --git a/pyproject.toml b/pyproject.toml index f5ce1f42..841aec15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,14 +8,13 @@ license = "Apache-2.0" description = "HomeKit Accessory Protocol implementation in python" readme = "README.md" authors = [{ name = "Ivan Kalchev" }] -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -23,7 +22,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "async_timeout;python_version<'3.11'", "cryptography", "chacha20poly1305-reuseable", "orjson>=3.7.2", @@ -61,7 +59,7 @@ version = { attr = "pyhap.const.__version__" } [tool.ruff] line-length = 88 -target-version = "py310" +target-version = "py311" # Directories previously excluded from black. exclude = ["generated"] @@ -146,7 +144,7 @@ include = [ ] [tool.ty.environment] -python-version = "3.10" +python-version = "3.11" [tool.pytest.ini_options] testpaths = [ diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 28d64911..4b83f040 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -1104,7 +1104,7 @@ async def test_camera_snapshot_times_out(driver: AccessoryDriver) -> None: connections = {} def _get_snapshot(*_: Any) -> NoReturn: - raise asyncio.TimeoutError("timeout") + raise TimeoutError("timeout") acc = Accessory(driver, "TestAcc") # Camera-only snapshot method, attached dynamically so the diff --git a/tox.ini b/tox.ini index cabd6d4a..f245245d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py310, py311, py312, py313, docs, lint, ty +envlist = py311, py312, py313, docs, lint, ty skip_missing_interpreters = True [gh-actions] python = - 3.10: py310 3.11: py311 3.12: py312 3.13: py313 @@ -64,8 +63,5 @@ extras = bench deps = -r{toxinidir}/requirements_test.txt - # ty type checks against python-version 3.10, where hap_handler - # imports async_timeout instead of asyncio.timeout. - async_timeout commands = ty check From 3bc3d7a0c2eaa7c0e4a9099a788635707b19810d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:12:57 +0100 Subject: [PATCH 22/56] Tell accessories when a controller's connection closes An accessory that keeps per-controller state, such as camera streams a controller set up, has no way to learn that the controller went away. The driver now calls Accessory.client_disconnected() from connection_lost, and a bridge passes it on to each bridged accessory. An accessory that raises is logged and does not stop the others or the rest of the connection cleanup. --- pyhap/accessory.py | 17 +++++++++++++ pyhap/accessory_driver.py | 9 +++++++ tests/test_accessory_driver.py | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/pyhap/accessory.py b/pyhap/accessory.py index cd8a188d..78538e20 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -333,6 +333,13 @@ def restore_accessory_state(self) -> None: built before that. Does nothing by default. """ + def client_disconnected(self, client_addr: tuple[str, int]) -> None: + """Called in the event loop when a controller's connection closed. + + Override to end what the controller started, e.g. its streams. Does + nothing by default. + """ + def reset_accessory_state(self) -> None: """Forget settings made by the previous home. @@ -456,6 +463,16 @@ def restore_accessory_state(self) -> None: "Failed to restore the state of %s", acc.display_name, exc_info=True ) + def client_disconnected(self, client_addr: tuple[str, int]) -> None: + """Tell each bridged accessory about the closed connection.""" + for acc in self.accessories.values(): + try: + acc.client_disconnected(client_addr) + except Exception: + logger.exception( + "%s failed to handle a closed connection", acc.display_name + ) + def reset_accessory_state(self) -> None: """Reset the state of each bridged accessory.""" for acc in self.accessories.values(): diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index a68eae6c..309c9d0f 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -581,6 +581,15 @@ def connection_lost(self, client: Any) -> None: for topic in client_topics: self.async_subscribe_client_topic(client, topic, subscribe=False) self.prepared_writes.pop(client, None) + accessory = self.accessory + if accessory is None: + return + try: + accessory.client_disconnected(client) + except Exception: + logger.exception( + "%s failed to handle a closed connection", accessory.display_name + ) def publish( self, diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 2e3f389d..237f439a 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1441,3 +1441,47 @@ def test_prepare_bounds_prepared_writes(driver: AccessoryDriver) -> None: # The earliest-registered pids were evicted, the latest kept. assert 0 not in pending assert MAX_PREPARED_WRITES_PER_CLIENT + 4 in pending + + +CLIENT = ("1.2.3.4", 5) + + +def test_connection_lost_tells_the_accessory(driver: AccessoryDriver) -> None: + """Test the accessory hears about a closed connection.""" + acc = Accessory(driver, "TestAcc") + driver.add_accessory(acc) + with patch.object(acc, "client_disconnected") as disconnected: + driver.connection_lost(CLIENT) + disconnected.assert_called_once_with(CLIENT) + + +def test_connection_lost_logs_accessory_errors( + driver: AccessoryDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a failing accessory does not break the connection cleanup.""" + acc = Accessory(driver, "TestAcc") + driver.add_accessory(acc) + driver.prepared_writes[CLIENT] = {} + with patch.object(acc, "client_disconnected", side_effect=RuntimeError): + driver.connection_lost(CLIENT) + assert CLIENT not in driver.prepared_writes + assert "TestAcc failed to handle a closed connection" in caplog.text + + +def test_bridge_tells_each_accessory( + driver: AccessoryDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a bridge passes a closed connection on, despite failing accessories.""" + bridge = Bridge(driver, "TestBridge") + first = Accessory(driver, "First") + second = Accessory(driver, "Second") + bridge.add_accessory(first) + bridge.add_accessory(second) + driver.add_accessory(bridge) + with ( + patch.object(first, "client_disconnected", side_effect=RuntimeError), + patch.object(second, "client_disconnected") as disconnected, + ): + driver.connection_lost(CLIENT) + disconnected.assert_called_once_with(CLIENT) + assert "First failed to handle a closed connection" in caplog.text From c7e554dd9a1c7356dd37cc073b69b53639b75c04 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:14:27 +0100 Subject: [PATCH 23/56] Accept only a canonical IP address in Setup Endpoints The controller address of a Setup Endpoints write is now accepted only as the canonical IP of the announced version, without an IPv6 scope ID. Any other address, a missing address, port or SRTP field, a version or port field of the wrong length or an empty crypto suite answers with an error and sets up no session. set_endpoints() also returns that error answer. The default start_stream also builds its argument list by formatting each argument of the command template, instead of splitting the formatted command. --- pyhap/camera.py | 168 +++++++++++++++++++++++++++++-------------- tests/test_camera.py | 167 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 55 deletions(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index 4caccd4e..27b55f93 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -250,6 +250,94 @@ ) +def parse_setup_address(value: bytes, is_ipv6: bool) -> str: + """Return the controller address of a Setup Endpoints write. + + Only the canonical IP of the announced version is accepted, without an + IPv6 scope ID. + + :raises ValueError: If the address is anything else. + """ + try: + address = value.decode("utf-8") + parsed = ipaddress.ip_address(address) + except ValueError as ex: + raise ValueError(f"invalid address {value!r}") from ex + if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None: + raise ValueError(f"address {address!r} has a scope ID") + if str(parsed) != address: + raise ValueError(f"address {address!r} is not in canonical form") + if parsed.version != (6 if is_ipv6 else 4): + raise ValueError(f"address {address!r} does not match its IP version") + return address + + +def _decode_setup_endpoints( + session_id: UUID, objs: dict[bytes, Any] +) -> tuple[str, bool, int, int, tuple[bytes, bytes], tuple[bytes, bytes]]: + """Decode a Setup Endpoints write. + + Returns the controller address, whether it is IPv6, its video and audio + ports and the video and audio SRTP master key and salt. + + :raises ValueError: If the address is not a valid IP address. + :raises KeyError: If a field is missing. + :raises struct.error: If a field is too short. + :raises IndexError: If a crypto suite is empty. + """ + address_info = tlv.decode(objs[SETUP_TYPES["ADDRESS"]]) + is_ipv6 = struct.unpack("?", address_info[SETUP_ADDR_INFO["ADDRESS_VER"]])[0] + address = parse_setup_address( + bytes(address_info[SETUP_ADDR_INFO["ADDRESS"]]), is_ipv6 + ) + video_port, audio_port = ( + int(struct.unpack(" Any: + """Encode a Setup Endpoints answer that has only a status.""" + return tlv.encode( + SETUP_TYPES["SESSION_ID"], + session_id.bytes, + SETUP_TYPES["STATUS"], + status, + to_base64=True, + ) + + class Camera(Accessory): """An Accessory that can negotiate camera stream settings with a HAP client (e.g. iOS) and start a stream. @@ -1254,11 +1342,12 @@ def set_streaming_available(self, stream_idx: int) -> None: self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"] self._management[stream_idx].get_characteristic("StreamingStatus").notify() - def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: + def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: """Configure streaming endpoints. Called when iOS sets the SetupEndpoints ``Characteristic``. The endpoint information for the camera should be set as the current value of SetupEndpoints. + A refused write sets and returns the error answer. :param value: The base64-encoded stream session details in TLV format. :param value: ``str`` @@ -1269,52 +1358,24 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: objs = tlv.decode(value, from_base64=True) session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]]) - # Extract address info - address_tlv = objs[SETUP_TYPES["ADDRESS"]] - address_info_objs = tlv.decode(address_tlv) - is_ipv6 = struct.unpack("?", address_info_objs[SETUP_ADDR_INFO["ADDRESS_VER"]])[ - 0 - ] - address = address_info_objs[SETUP_ADDR_INFO["ADDRESS"]].decode("utf8") - target_video_port = struct.unpack( - " None: """Reject controller-supplied values unsafe to format into a command. - The default start stream command is built by formatting the negotiated - configuration into a template and splitting it on whitespace before - exec, so a controller-supplied string containing whitespace could - inject additional command-line arguments into the streaming process. - The stream address must be a valid IP address (per the HAP spec that is - all a controller may send) and no formatted string value may contain - whitespace. + The default start stream command formats each argument of its + template with the negotiated configuration. The stream address must + be a valid IP address (per the HAP spec that is all a controller may + send) and no formatted string value may contain whitespace. :raises ValueError: If any value is unsafe. """ diff --git a/tests/test_camera.py b/tests/test_camera.py index 9d81af8b..0c237d93 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -420,3 +420,170 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: ): assert await acc.start_stream(session_info, safe_config) is True exec_mock.assert_called_once() + + +_SESSION_ID = UUID("acccc6cc-1505-6345-158d-a974b4509006") + + +async def test_start_stream_formats_each_argument(mock_driver: Any) -> None: + """Test the default command formats each template argument on its own. + + A non-string value whose text has a space stays one argument. + """ + acc = camera.Camera( + {**_OPTIONS, "start_stream_cmd": "ffmpeg -i x srtp://{address}:{v_port}"}, + mock_driver, + "Camera", + ) + with patch.object( + asyncio, "create_subprocess_exec", return_value=Mock(pid=42) + ) as exec_mock: + assert await acc.start_stream( + {"id": _SESSION_ID}, {"address": "192.168.1.114", "v_port": (1, 2)} + ) + assert exec_mock.call_args.args == ( + "ffmpeg", + "-i", + "x", + "srtp://192.168.1.114:(1, 2)", + ) + + +def _address_tlv(address: bytes, is_ipv6: bool) -> bytes: + """Encode the address info of a Setup Endpoints write.""" + return tlv.encode( + camera.SETUP_ADDR_INFO["ADDRESS_VER"], + struct.pack("?", is_ipv6), + camera.SETUP_ADDR_INFO["ADDRESS"], + address, + camera.SETUP_ADDR_INFO["VIDEO_RTP_PORT"], + struct.pack(" str: + """Build a Setup Endpoints write for the controller address.""" + return _setup_request_with(_address_tlv(address, is_ipv6)) + + +def _setup_request_with(address_tlv: bytes, crypto: bytes = b"\x00") -> str: + """Build a Setup Endpoints write with the given address info.""" + srtp = tlv.encode( + camera.SETUP_SRTP_PARAM["CRYPTO"], + crypto, + camera.SETUP_SRTP_PARAM["MASTER_KEY"], + bytes(16), + camera.SETUP_SRTP_PARAM["MASTER_SALT"], + bytes(14), + ) + return tlv.encode( + camera.SETUP_TYPES["SESSION_ID"], + _SESSION_ID.bytes, + camera.SETUP_TYPES["ADDRESS"], + address_tlv, + camera.SETUP_TYPES["VIDEO_SRTP_PARAM"], + srtp, + camera.SETUP_TYPES["AUDIO_SRTP_PARAM"], + srtp, + to_base64=True, + ) + + +@pytest.mark.parametrize( + ("address", "is_ipv6"), + [ + (b"192.168.1.114", False), + (b"fd00::1", True), + ], +) +def test_setup_endpoints_accepts_addresses( + mock_driver: Any, address: bytes, is_ipv6: bool +) -> None: + """Test canonical addresses of the announced version are accepted.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + management.get_characteristic("SetupEndpoints").client_update_value( + _setup_request(address, is_ipv6) + ) + assert acc.sessions[_SESSION_ID]["address"] == address.decode() + + +@pytest.mark.parametrize( + ("address", "is_ipv6"), + [ + (b"192.168.1.114 x", False), + (b"192.168.1.114\n", False), + (b"fe80::1%en0", True), + (b"FD00::1", True), + (b"fd00:0:0:0:0:0:0:1", True), + (b"192.168.1.114", True), + (b"fd00::1", False), + (b"::ffff:192.168.1.114", False), + (b"\xff", False), + (b"", False), + ], +) +def test_setup_endpoints_refuses_addresses( + mock_driver: Any, address: bytes, is_ipv6: bool +) -> None: + """Test other addresses answer an error and set up no session.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + setup_endpoints = management.get_characteristic("SetupEndpoints") + response = setup_endpoints.client_update_value(_setup_request(address, is_ipv6)) + assert acc.sessions == {} + assert tlv.decode(response, from_base64=True) == { + camera.SETUP_TYPES["SESSION_ID"]: _SESSION_ID.bytes, + camera.SETUP_TYPES["STATUS"]: camera.SETUP_STATUS["ERROR"], + } + assert setup_endpoints.get_value() == response + + +@pytest.mark.parametrize( + "request_value", + [ + _setup_request_with( + tlv.encode( + camera.SETUP_ADDR_INFO["ADDRESS_VER"], + struct.pack("?", False), + camera.SETUP_ADDR_INFO["ADDRESS"], + b"192.168.1.114", + camera.SETUP_ADDR_INFO["VIDEO_RTP_PORT"], + struct.pack(" None: + """Test a malformed write answers an error and sets up no session.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + setup_endpoints = management.get_characteristic("SetupEndpoints") + response = setup_endpoints.client_update_value(request_value) + assert acc.sessions == {} + assert tlv.decode(response, from_base64=True) == { + camera.SETUP_TYPES["SESSION_ID"]: _SESSION_ID.bytes, + camera.SETUP_TYPES["STATUS"]: camera.SETUP_STATUS["ERROR"], + } + assert setup_endpoints.get_value() == response From af8d2c02451179dae55f762e48a15e4ef6d00612 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:16:35 +0100 Subject: [PATCH 24/56] Keep the Setup Endpoints answer per connection A controller writes Setup Endpoints and reads the answer back. The answer carries the session keys, so it now stays with the connection that wrote, like a write response. A characteristic with read_back_response keeps what its setter returns for the writing connection, until it writes again or closes. Other connections and /accessories read the characteristic's own value, and the written request is not left as that value either. The same now holds for any write to a readable write response characteristic, with or without r. set_endpoints() returns its answer instead of setting the value. --- pyhap/accessory_driver.py | 48 +++++++++++++++++++++-------- pyhap/camera.py | 15 +++++----- pyhap/characteristic.py | 5 ++++ tests/test_accessory_driver.py | 55 ++++++++++++++++++++++++++++++---- tests/test_camera.py | 40 ++++++++++++++++++++++--- 5 files changed, 133 insertions(+), 30 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 665ae5ba..aaab755d 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1256,12 +1256,16 @@ def _run_characteristic_writes( and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] ) # A new write replaces a former response of this connection. The - # request of a readable write response characteristic is not - # shared, whatever the setter does, except to ev subscribers. + # request of a readable write response or read back characteristic + # is not shared, with or without r and whatever the setter does, + # except to ev subscribers. if value is not None: self._write_responses.get(client_addr, {}).pop(char, None) private_write = ( - write_response + ( + HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] + or char.read_back_response + ) and value is not None and HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] ) @@ -1285,9 +1289,9 @@ def _run_characteristic_writes( result: dict[str, Any] = {HAP_REPR_STATUS: set_result} if inspect.iscoroutine(set_result_value): pending.append((result, set_result_value, write_response, char)) - elif set_result_value is not None and write_response: - result[HAP_REPR_VALUE] = self._store_write_response( - char, set_result_value, client_addr + elif set_result_value is not None: + self._keep_response( + result, char, set_result_value, write_response, client_addr ) results[aid][iid] = result @@ -1363,14 +1367,31 @@ async def _async_finish_set_characteristics( HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE ) continue - if outcome is not None and write_response: + if outcome is not None: # A response queued when the connection closes is dropped by # the deferred cleanup in connection_lost. - result[HAP_REPR_VALUE] = self._store_write_response( - char, outcome, client_addr - ) + self._keep_response(result, char, outcome, write_response, client_addr) return _set_characteristics_response(results) + def _keep_response( + self, + result: dict[str, Any], + char: Characteristic, + response: HAPValue, + write_response: bool, + client_addr: tuple[str, int] | None, + ) -> None: + """Keep what a setter returned as a write response or read back value.""" + if write_response: + result[HAP_REPR_VALUE] = self._store_write_response( + char, response, client_addr + ) + elif ( + char.read_back_response + and HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] + ): + self._store_write_response(char, response, client_addr) + def _take_prepared_write(self, pid: Any, client_addr: tuple[str, int]) -> bool: """Consume the prepared write ``pid`` and return whether it is still valid.""" expires_at = self.prepared_writes.get(client_addr, {}).pop(pid, None) @@ -1389,9 +1410,10 @@ def _store_write_response( characteristic is readable, only the connection that wrote reads the response back, until it closes, and it wins over the getter and a later ``set_value``. Other connections read the characteristic's own value, - which the write leaves as it was. A write without ``r`` stays readable - for everyone, and a characteristic that also has ``ev`` notifies the - written value as any write does. + which the write leaves as it was, also for a write without ``r``. A + characteristic that also has ``ev`` notifies the written value as any + write does. A read back characteristic keeps the response the same + way, without a write response. """ readable = HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] try: diff --git a/pyhap/camera.py b/pyhap/camera.py index 4caccd4e..8daca080 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -590,12 +590,14 @@ def _create_stream_management( "SelectedRTPStreamConfiguration", setter_callback=self.set_selected_stream_configuration, ) - management.configure_char( + setup_endpoints = management.configure_char( "SetupEndpoints", setter_callback=lambda value: self.set_endpoints( value, stream_idx=stream_idx ), ) + # The answer has the session's SRTP keys, only its controller reads it. + setup_endpoints.read_back_response = True return management def _default_recording_configs( @@ -1254,11 +1256,12 @@ def set_streaming_available(self, stream_idx: int) -> None: self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"] self._management[stream_idx].get_characteristic("StreamingStatus").notify() - def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: + def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: """Configure streaming endpoints. - Called when iOS sets the SetupEndpoints ``Characteristic``. The endpoint - information for the camera should be set as the current value of SetupEndpoints. + Called when iOS sets the SetupEndpoints ``Characteristic``. Returns the + endpoint information of the camera, which only the connection that + wrote reads back. :param value: The base64-encoded stream session details in TLV format. :param value: ``str`` @@ -1412,9 +1415,7 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: "audio_backchannel": audio_backchannel, } - self._management[stream_idx].get_characteristic("SetupEndpoints").set_value( - response_tlv - ) + return response_tlv async def run(self) -> None: """Start the HDS listener when recording transport is available.""" diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index 4472638b..b22ac87d 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -160,6 +160,7 @@ class Characteristic: "_always_null", "_setter_addr_cache", "_setter_addr_cache_for", + "read_back_response", ) def __init__( @@ -216,6 +217,10 @@ def __init__( self._loader_display_name: str | None = None self._to_hap_cache_with_value: dict[str, Any] | None = None self._to_hap_cache: dict[str, Any] | None = None + # For a control point a controller writes and then reads, such as + # Setup Endpoints: the setter's return value is what the writing + # connection reads back, like a write response. + self.read_back_response = False @property def display_name(self) -> str | None: diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 82a2ccde..9c92a4b1 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1333,14 +1333,14 @@ async def test_write_response_dropped_on_disconnect(driver: AccessoryDriver) -> def test_plain_write_replaces_write_response(driver: AccessoryDriver) -> None: - """A write without ``r`` drops the writer's former response.""" + """A write without ``r`` drops the writer's former response, privately too.""" acc, char, char_id = _add_write_response_char(driver, False) _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) _write(driver, acc, char, **{HAP_REPR_VALUE: 9}) - assert _read(driver, char_id, WRITER) == 9 - assert _read(driver, char_id, OTHER_CONTROLLER) == 9 + assert _read(driver, char_id, WRITER) == 0 + assert _read(driver, char_id, OTHER_CONTROLLER) == 0 @pytest.mark.parametrize( @@ -1451,15 +1451,15 @@ def test_write_response_without_client_not_kept(driver: AccessoryDriver) -> None pytest.param({HAP_REPR_WRITE_RESPONSE: False}, id="declined"), ], ) -def test_written_value_readable_without_write_response( +def test_written_value_private_without_write_response( driver: AccessoryDriver, reader: Any, query: dict[str, Any] ) -> None: - """Without a write response the written value is read by every controller.""" + """Without a write response the written value is not left readable either.""" acc, char, char_id = _add_write_response_char(driver, False) _write(driver, acc, char, **{HAP_REPR_VALUE: 5, **query}) - assert _read(driver, char_id, reader) == 5 + assert _read(driver, char_id, reader) == 0 def test_service_callbacks(driver: AccessoryDriver) -> None: @@ -2570,3 +2570,46 @@ def test_prepare_bounds_prepared_writes(driver: AccessoryDriver) -> None: # The earliest-registered pids were evicted, the latest kept. assert 0 not in pending assert MAX_PREPARED_WRITES_PER_CLIENT + 4 in pending + + +def _add_read_back_char(driver: AccessoryDriver) -> tuple[Accessory, Characteristic]: + """Add a pr and pw char that answers a write with 7 to read back.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw"], False) + char.getter_callback = None + char.setter_callback = MagicMock(return_value=7) + char.read_back_response = True + return acc, char + + +@pytest.mark.parametrize( + ("reader", "expected"), + [ + pytest.param(WRITER, 7, id="writer"), + pytest.param(OTHER_CONTROLLER, 0, id="other-controller"), + ], +) +def test_read_back_response_only_for_writer( + driver: AccessoryDriver, reader: Any, expected: int +) -> None: + """Only the writing controller reads back what the setter answered.""" + acc, char = _add_read_back_char(driver) + + assert _write(driver, acc, char, **{HAP_REPR_VALUE: 5}) is None + + assert _read(driver, f"{acc.aid}.{acc.iid_manager.get_iid(char)}", reader) == ( + expected + ) + acc_rep = driver.get_accessories()[HAP_REPR_ACCS][-1] + assert acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 0 + + +async def test_async_read_back_response(driver: AccessoryDriver) -> None: + """A coroutine setter's answer is read back, not sent as a write response.""" + acc, char = _add_read_back_char(driver) + char.setter_callback = _async_return_one + + assert await _controller_write(driver, acc, char, **{HAP_REPR_VALUE: 5}) is None + + char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + assert _read(driver, char_id, WRITER) == 1 + assert _read(driver, char_id, OTHER_CONTROLLER) == 0 diff --git a/tests/test_camera.py b/tests/test_camera.py index 9d81af8b..d70e7fd0 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -90,9 +90,9 @@ def test_setup_endpoints(mock_driver: Any) -> None: management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - setup_endpoints.client_update_value(set_endpoint_req) + response = setup_endpoints.client_update_value(set_endpoint_req) - assert setup_endpoints.get_value()[:171] == set_endpoint_res[:171] + assert response[:171] == set_endpoint_res[:171] def test_setup_endpoints_does_not_log_srtp_keys( @@ -270,13 +270,13 @@ def test_setup_endpoints_reports_real_port_when_talkback_enabled( management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - setup_endpoints.client_update_value(set_endpoint_req) + response = setup_endpoints.client_update_value(set_endpoint_req) session_id = list(acc.sessions)[0] receiver = acc.sessions[session_id]["audio_backchannel"] assert receiver is not None - reported_port = _accessory_audio_port(setup_endpoints.get_value()) + reported_port = _accessory_audio_port(response) assert reported_port == receiver.local_port assert reported_port != _controller_audio_port(set_endpoint_req) @@ -420,3 +420,35 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: ): assert await acc.start_stream(session_info, safe_config) is True exec_mock.assert_called_once() + + +def test_setup_endpoints_read_back_by_writer_only(driver: Any) -> None: + """Test only the controller that set up a session reads its answer.""" + acc = camera.Camera(_OPTIONS, driver, "Camera") + driver.add_accessory(acc) + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + char = management.get_characteristic("SetupEndpoints") + char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + request = ( + "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" + "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" + "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" + ) + query = { + "characteristics": [ + {"aid": acc.aid, "iid": acc.iid_manager.get_iid(char), "value": request} + ] + } + + assert driver.set_characteristics(query, ("192.168.1.114", 50000)) is None + + (writer,) = driver.get_characteristics([char_id], ("192.168.1.114", 50000))[ + "characteristics" + ] + (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ + "characteristics" + ] + assert writer["value"][:20] == "ARCszGzBBWNFFY2pdLRQ" + assert other["value"] == "" + assert char.value == "" From 3def9b4fa573d3487a879713e706437850969634 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:31:32 +0100 Subject: [PATCH 25/56] Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management The Camera Multi-Tier RTP Stream Management service of the HomeKit Secure Video 17.99 guide advertises video and audio stream tiers, sets up a session with Setup Endpoints and starts and ends streams through RTP Streaming Control. Add the video and audio stream tiers and their Supported Video/Audio Stream Tiers values, the decoder for RTP Streaming Control writes and the encoder for its read back status, the Setup Endpoints request and answer, and tiers_from_sensor() to offer the video encodings of a sensor as tiers. Unknown commands are kept as plain ints, like the other enums of the module. Tiers are frozen. A video tier also carries the peak bit rate of its encoding, which is not part of the TLV. The payload types 99 and 110 are constants, so the RTP tiers, the WebRTC tiers and a WebRTC offer agree. check_audio_tier() and check_video_tiers() hold the tiers against the guide: 20 ms mono Opus, at least one video encoding, known qualities, Highest only with High and a peak not below the average. A gap of more than 10 % of the peak and peaks above the guide's bit rate table only warn, the guide recommends them. A frame between two rows of the table takes the larger one, and a frame above 4K warns. The module docstring now says the API is experimental. --- pyhap/hksv3.py | 437 +++++++++++++++++++++++++++++++++++++++++++- tests/test_hksv3.py | 282 ++++++++++++++++++++++++++++ 2 files changed, 718 insertions(+), 1 deletion(-) diff --git a/pyhap/hksv3.py b/pyhap/hksv3.py index a7d6a10c..e972ce49 100644 --- a/pyhap/hksv3.py +++ b/pyhap/hksv3.py @@ -1,5 +1,8 @@ """TLV8 values of the HomeKit Secure Video 17.99 camera services. +Developer Preview: HomeKit Secure Video 17.99 is a preview of the guide, so +this API is experimental and may change. + Follows the HAP TLV8 list encoding: each item of a list is a TLV of its own, and items of the same type are separated by an empty TLV of type 0. Values longer than 255 bytes are sent as 255-byte fragments of the same type; a TLV @@ -8,7 +11,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from enum import IntEnum from typing import TypeVar @@ -22,6 +25,7 @@ ZONES_VERSION = 2 _E = TypeVar("_E", bound=IntEnum) +_T = TypeVar("_T") class SensorType(IntEnum): @@ -278,3 +282,434 @@ def decode(cls, data: bytes) -> CameraZones: if version != ZONES_VERSION: return cls([], version) return cls([ZoneData.decode(zone) for zone in fields.get(2, [])], version) + + +class VideoCodec(IntEnum): + H264 = 1 + H265 = 2 + + +class AudioCodec(IntEnum): + OPUS = 3 + + +class AudioSampleRate(IntEnum): + KHZ_16 = 1 + KHZ_24 = 2 + KHZ_32 = 3 + KHZ_48 = 4 + + +class AudioBitDepth(IntEnum): + BITS_8 = 1 + BITS_16 = 2 + BITS_24 = 3 + + +class RTPStreamingCommand(IntEnum): + END = 1 + START = 2 + + +class RTPStreamingStatus(IntEnum): + SUCCESS = 0 + UNKNOWN_SESSION = 1 + NO_SUCH_STREAM = 2 + BUSY = 3 + ERROR = 4 + + +# The payload types of the stream tier characteristics. WebRTC media and its +# SDP use the same ones. +VIDEO_PAYLOAD_TYPE = 99 +AUDIO_PAYLOAD_TYPE = 110 + + +@dataclass(frozen=True) +class VideoStreamTier: + """A video tier a controller can pick for a stream, bit rates in kbps. + + ``peak_bitrate`` is not part of the TLV. :func:`tiers_from_sensor` takes + it from the sensor's video encoding, e.g. for the ``max-br`` of a WebRTC + offer. + """ + + identifier: int + quality: VideoQuality | int + target_bitrate: int + width: int + height: int + frame_rate: int + peak_bitrate: int | None = None + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, _uint(self.identifier, 4)), + encode_item(2, _uint(self.quality, 1)), + encode_item(3, _uint(self.target_bitrate, 4)), + encode_item(4, _uint(self.width, 2)), + encode_item(5, _uint(self.height, 2)), + encode_item(6, _uint(self.frame_rate, 1)), + ) + ) + + +@dataclass(frozen=True) +class AudioStreamTier: + """An audio tier a controller can pick for a stream, bit rate in bps. + + With Opus the guide has the sample rate of the RTP stream, always 48 kHz, + whatever the capture rate. + """ + + identifier: int + target_bitrate: int + sample_rate: AudioSampleRate | int + bit_depth: AudioBitDepth | int = AudioBitDepth.BITS_16 + packet_time: int = 20 + channels: int = 1 + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, _uint(self.identifier, 4)), + encode_item(2, _uint(self.target_bitrate, 4)), + encode_item(3, _uint(self.sample_rate, 1)), + encode_item(4, _uint(self.bit_depth, 1)), + encode_item(5, _uint(self.packet_time, 1)), + encode_item(6, _uint(self.channels, 1)), + ) + ) + + +@dataclass +class SupportedVideoStreamTiers: + """Value of the Supported Video Stream Tiers characteristic.""" + + codec: VideoCodec | int + payload_type: int + tiers: list[VideoStreamTier] + + def encode(self) -> bytes: + return ( + encode_item(1, _uint(self.codec, 1)) + + encode_item(2, _uint(self.payload_type, 1)) + + encode_list(3, (tier.encode() for tier in self.tiers)) + ) + + +@dataclass +class SupportedAudioStreamTiers: + """Value of the Supported Audio Stream Tiers characteristic.""" + + codec: AudioCodec | int + payload_type: int + tiers: list[AudioStreamTier] + + def encode(self) -> bytes: + return ( + encode_item(1, _uint(self.codec, 1)) + + encode_item(2, _uint(self.payload_type, 1)) + + encode_list(3, (tier.encode() for tier in self.tiers)) + ) + + +def _optional_int(fields: dict[int, list[bytes]], tag: int) -> int | None: + return _to_int(fields[tag][0]) if tag in fields else None + + +def _optional( + fields: dict[int, list[bytes]], tag: int, convert: Callable[[bytes], _T] +) -> _T | None: + return convert(fields[tag][0]) if tag in fields else None + + +def _optional_item(tag: int, value: bytes | None) -> bytes: + return b"" if value is None else encode_item(tag, value) + + +@dataclass +class RTPStreamingControl: + """A write to the RTP Streaming Control characteristic. + + Tiers and SSRCs come only with :attr:`RTPStreamingCommand.START`, the + audio ones only if the controller wants audio. + """ + + session_id: UUID + command: RTPStreamingCommand | int + video_tier: int | None = None + video_ssrc: int | None = None + audio_tier: int | None = None + audio_ssrc: int | None = None + + @classmethod + def decode(cls, data: bytes) -> RTPStreamingControl: + fields = _fields(data) + return cls( + _to_uuid(_required(fields, 1)), + _to_enum(RTPStreamingCommand, _required(fields, 2)), + _optional_int(fields, 3), + _optional_int(fields, 4), + _optional_int(fields, 5), + _optional_int(fields, 6), + ) + + +def encode_streaming_status( + session_id: UUID | None, status: RTPStreamingStatus | int +) -> bytes: + """Encode the read back value of RTP Streaming Control. + + Without a session, e.g. for a write that did not decode, only the status + is sent. + """ + session = b"" if session_id is None else encode_item(1, session_id.bytes) + return session + encode_item(2, _uint(status, 1)) + + +class SetupEndpointsStatus(IntEnum): + SUCCESS = 0 + BUSY = 1 + ERROR = 2 + + +class SRTPCryptoSuite(IntEnum): + AES_CM_128_HMAC_SHA1_80 = 0 + AES_256_CM_HMAC_SHA1_80 = 1 + DISABLED = 2 + + +def _port(data: bytes) -> int: + if len(data) != 2: + raise ValueError(f"Invalid port length {len(data)}") + return _to_int(data) + + +@dataclass(frozen=True) +class EndpointAddress: + """An address of Setup Endpoints and its RTP ports. + + ``address`` is the text the controller sent. Check it with + :func:`pyhap.camera.parse_setup_address` before using it. + """ + + address: str + is_ipv6: bool + video_port: int + audio_port: int + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, _uint(self.is_ipv6, 1)), + encode_item(2, self.address.encode("utf-8")), + encode_item(3, _uint(self.video_port, 2)), + encode_item(4, _uint(self.audio_port, 2)), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> EndpointAddress: + fields = _fields(data) + return cls( + _required(fields, 2).decode("utf-8"), + bool(_to_int(_required(fields, 1))), + _port(_required(fields, 3)), + _port(_required(fields, 4)), + ) + + +@dataclass(frozen=True) +class SRTPParameters: + """The SRTP crypto suite, master key and master salt of a stream.""" + + crypto_suite: SRTPCryptoSuite | int + master_key: bytes = field(repr=False) + master_salt: bytes = field(repr=False) + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, _uint(self.crypto_suite, 1)), + encode_item(2, self.master_key), + encode_item(3, self.master_salt), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> SRTPParameters: + fields = _fields(data) + return cls( + _to_enum(SRTPCryptoSuite, _required(fields, 1)), + _required(fields, 2), + _required(fields, 3), + ) + + +@dataclass(frozen=True) +class SetupEndpoints: + """A write to Setup Endpoints: where the controller receives, and its keys.""" + + session_id: UUID + address: EndpointAddress + video_srtp: SRTPParameters + audio_srtp: SRTPParameters + + def encode(self) -> bytes: + return b"".join( + ( + encode_item(1, self.session_id.bytes), + encode_item(3, self.address.encode()), + encode_item(4, self.video_srtp.encode()), + encode_item(5, self.audio_srtp.encode()), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> SetupEndpoints: + fields = _fields(data) + return cls( + _to_uuid(_required(fields, 1)), + EndpointAddress.decode(_required(fields, 3)), + SRTPParameters.decode(_required(fields, 4)), + SRTPParameters.decode(_required(fields, 5)), + ) + + +@dataclass(frozen=True) +class SetupEndpointsResponse: + """The read back value of Setup Endpoints. + + Without a session, e.g. for a write that did not decode, only the status + is sent. Only a successful one has the accessory's address, keys and + SSRCs. + """ + + session_id: UUID | None + status: SetupEndpointsStatus | int + address: EndpointAddress | None = None + video_srtp: SRTPParameters | None = None + audio_srtp: SRTPParameters | None = None + video_ssrc: int | None = None + audio_ssrc: int | None = None + + def encode(self) -> bytes: + def encoded(value: EndpointAddress | SRTPParameters | None) -> bytes | None: + return None if value is None else value.encode() + + def ssrc(value: int | None) -> bytes | None: + return None if value is None else _uint(value, 4) + + session_id = None if self.session_id is None else self.session_id.bytes + return b"".join( + ( + _optional_item(1, session_id), + encode_item(2, _uint(self.status, 1)), + _optional_item(3, encoded(self.address)), + _optional_item(4, encoded(self.video_srtp)), + _optional_item(5, encoded(self.audio_srtp)), + _optional_item(6, ssrc(self.video_ssrc)), + _optional_item(7, ssrc(self.audio_ssrc)), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> SetupEndpointsResponse: + fields = _fields(data) + return cls( + _optional(fields, 1, _to_uuid), + _to_enum(SetupEndpointsStatus, _required(fields, 2)), + _optional(fields, 3, EndpointAddress.decode), + _optional(fields, 4, SRTPParameters.decode), + _optional(fields, 5, SRTPParameters.decode), + _optional_int(fields, 6), + _optional_int(fields, 7), + ) + + +def tiers_from_sensor(sensor: SensorConfiguration) -> list[VideoStreamTier]: + """Offer each video encoding of the sensor as a tier, numbered from 1. + + Tier ``n`` is ``sensor.video_streams[n - 1]``. + """ + return [ + VideoStreamTier( + index, + stream.quality, + stream.average_bitrate, + stream.width, + stream.height, + stream.frame_rate, + stream.peak_bitrate, + ) + for index, stream in enumerate(sensor.video_streams, 1) + ] + + +DEFAULT_AUDIO_TIER = AudioStreamTier(1, 24000, AudioSampleRate.KHZ_48) + +# Average and maximum kbps of the guide's bit rate table, by the short side +# of the frame; the aspect ratio does not matter. A frame between two rows +# takes the larger one. +_GUIDE_BITRATES = { + 2160: (4500, 5000), + 1440: (2800, 3000), + 1080: (1700, 1800), + 720: (768, 800), + 360: (180, 190), + 240: (180, 190), +} + + +def check_audio_tier(tier: AudioStreamTier) -> None: + """Check an audio tier against the guide. + + :raises ValueError: For anything this revision of the guide does not + allow: a packet time other than 20 ms, more than one channel or an + unknown sample rate or bit depth. + """ + if tier.packet_time != 20 or tier.channels != 1: + raise ValueError("Audio tiers have a 20 ms packet time and one channel") + if tier.sample_rate not in set(AudioSampleRate) or tier.bit_depth not in set( + AudioBitDepth + ): + raise ValueError("Unknown audio sample rate or bit depth") + if tier.target_bitrate <= 0: + raise ValueError("The audio bit rate must be positive") + + +def check_video_tiers(sensor: SensorConfiguration) -> list[str]: + """Check the video encodings of a sensor as tiers against the guide. + + Returns warnings for what the guide only recommends: a gap between peak + and average of more than 10 % of the peak, peaks above its table and + frames larger than 4K, which the table does not cover. + + :raises ValueError: For a sensor without encodings, an unknown quality, + Highest without High or a peak below the average. + """ + if not sensor.video_streams: + raise ValueError("A sensor needs at least one video encoding") + warnings = [] + qualities = {stream.quality for stream in sensor.video_streams} + if not qualities <= set(VideoQuality): + raise ValueError(f"Unknown video quality in {sorted(qualities)}") + if VideoQuality.HIGHEST in qualities and VideoQuality.HIGH not in qualities: + raise ValueError("A Highest video encoding needs a High one") + for stream in sensor.video_streams: + name = f"{stream.width}x{stream.height}@{stream.frame_rate}" + if stream.peak_bitrate < stream.average_bitrate: + raise ValueError(f"{name}: peak bit rate below the average") + if (stream.peak_bitrate - stream.average_bitrate) * 10 > stream.peak_bitrate: + warnings.append(f"{name}: average more than 10 % below the peak") + short_side = min(stream.width, stream.height) + rows = [row for row in sorted(_GUIDE_BITRATES) if row >= short_side] + if not rows: + warnings.append(f"{name}: larger than the guide's 4K bit rate table") + continue + guide = _GUIDE_BITRATES[rows[0]] + if stream.peak_bitrate > guide[1]: + warnings.append(f"{name}: peak above the guide's {guide[1]} kbps") + return warnings diff --git a/tests/test_hksv3.py b/tests/test_hksv3.py index 008bf588..e9c4e69a 100644 --- a/tests/test_hksv3.py +++ b/tests/test_hksv3.py @@ -7,13 +7,30 @@ from pyhap import hksv3 from pyhap.hksv3 import ( + AudioBitDepth, + AudioCodec, + AudioSampleRate, + AudioStreamTier, CameraCapabilities, CameraZones, + EndpointAddress, + RTPStreamingCommand, + RTPStreamingControl, + RTPStreamingStatus, SensorConfiguration, SensorIntent, SensorType, + SetupEndpoints, + SetupEndpointsResponse, + SetupEndpointsStatus, + SRTPCryptoSuite, + SRTPParameters, + SupportedAudioStreamTiers, + SupportedVideoStreamTiers, + VideoCodec, VideoQuality, VideoStreamCapability, + VideoStreamTier, ZoneData, ZoneMethod, ZonePolygon, @@ -369,3 +386,268 @@ def test_camera_zones_invalid(data: str) -> None: """Test malformed zones raise ValueError.""" with pytest.raises(ValueError): CameraZones.decode(bytes.fromhex(data)) + + +# 1920x1080@30 High at 1700 kbps as tier 1. +TIER_HEX = "010401000000 020102 0304a4060000 04028007 05023804 06011e" +# 640x360@15 Low at 180 kbps as tier 3. +LOW_TIER_HEX = "010403000000 020104 0304b4000000 04028002 05026801 06010f" +# Opus at 24 kbps, 48 kHz, 16 bit, 20 ms, mono as tier 1. +AUDIO_TIER_HEX = "010401000000 0204c05d0000 030104 040102 050114 060101" +SESSION = UUID("7f3c2a10-4b5d-4e6f-8a9b-0c1d2e3f4a5b") + + +def test_video_stream_tier_golden() -> None: + """Test a video tier byte for byte.""" + tier = VideoStreamTier(1, VideoQuality.HIGH, 1700, 1920, 1080, 30) + assert tier.encode() == bytes.fromhex(TIER_HEX) + + +def test_supported_video_stream_tiers_golden() -> None: + """Test the tiers are list items after the codec and payload type.""" + value = SupportedVideoStreamTiers( + VideoCodec.H265, + 99, + [ + VideoStreamTier(1, VideoQuality.HIGH, 1700, 1920, 1080, 30), + VideoStreamTier(3, VideoQuality.LOW, 180, 640, 360, 15), + ], + ) + encoded = value.encode() + assert encoded == bytes.fromhex( + "010102 020163 031a" + TIER_HEX + "0000 031a" + LOW_TIER_HEX + ) + assert reference_parse(encoded) == [ + (1, b"\x02"), + (2, b"\x63"), + (3, bytes.fromhex(TIER_HEX)), + (3, bytes.fromhex(LOW_TIER_HEX)), + ] + assert reference_parse(bytes.fromhex(LOW_TIER_HEX))[3] == (4, b"\x80\x02") + + +def test_supported_audio_stream_tiers_golden() -> None: + """Test an Opus tier with the defaults the guide allows.""" + value = SupportedAudioStreamTiers( + AudioCodec.OPUS, 110, [AudioStreamTier(1, 24000, AudioSampleRate.KHZ_48)] + ) + encoded = value.encode() + assert encoded == bytes.fromhex("010103 02016e 0318" + AUDIO_TIER_HEX) + assert reference_parse(encoded)[2] == (3, bytes.fromhex(AUDIO_TIER_HEX)) + assert AudioStreamTier(1, 24000, 1, AudioBitDepth.BITS_8).encode()[12:18] == ( + bytes.fromhex("030101 040101") + ) + + +def test_supported_stream_tiers_empty() -> None: + """Test a codec without tiers has no tier item.""" + assert SupportedAudioStreamTiers(AudioCodec.OPUS, 110, []).encode() == ( + bytes.fromhex("010103 02016e") + ) + + +def test_rtp_streaming_control_start() -> None: + """Test decoding a start with video and audio.""" + data = bytes.fromhex( + "0110" + SESSION.hex + "020102" + "040478563412 030402000000 0504 01000000 0604efbeadde" + ) + assert RTPStreamingControl.decode(data) == RTPStreamingControl( + SESSION, RTPStreamingCommand.START, 2, 0x12345678, 1, 0xDEADBEEF + ) + + +def test_rtp_streaming_control_end() -> None: + """Test an end has no tiers, short integers are accepted.""" + request = RTPStreamingControl.decode( + bytes.fromhex("0110" + SESSION.hex + "020101 030103") + ) + assert request == RTPStreamingControl(SESSION, RTPStreamingCommand.END, 3) + + +def test_rtp_streaming_control_unknown_values() -> None: + """Test unknown commands and types are kept and skipped.""" + request = RTPStreamingControl.decode( + bytes.fromhex("0110" + SESSION.hex + "020109 0902aabb") + ) + assert request.command == 9 + assert request.video_tier is None + + +@pytest.mark.parametrize( + "data", + [ + "020102", + "0110" + SESSION.hex, + "0104aabbccdd 020102", + "0110" + SESSION.hex + "020102 0300", + "0110" + SESSION.hex + "0201", + ], +) +def test_rtp_streaming_control_invalid(data: str) -> None: + """Test missing fields, bad lengths and truncated data raise.""" + with pytest.raises(ValueError): + RTPStreamingControl.decode(bytes.fromhex(data)) + + +def test_streaming_status() -> None: + """Test the read back value with and without a session.""" + encoded = hksv3.encode_streaming_status(SESSION, RTPStreamingStatus.BUSY) + assert encoded == bytes.fromhex("0110" + SESSION.hex + "020103") + assert reference_parse(encoded) == [(1, SESSION.bytes), (2, b"\x03")] + assert hksv3.encode_streaming_status(None, RTPStreamingStatus.ERROR) == ( + b"\x02\x01\x04" + ) + + +def test_tiers_from_sensor() -> None: + """Test each encoding becomes a tier numbered in order, with its peak.""" + sensor = _capabilities().sensors[0] + assert hksv3.tiers_from_sensor(sensor) == [ + VideoStreamTier(1, VideoQuality.HIGH, 1700, 1920, 1080, 30, 1800), + VideoStreamTier(2, VideoQuality.LOW, 180, 640, 360, 15, 190), + ] + assert hksv3.tiers_from_sensor(SensorConfiguration(SENSOR, 640, 360)) == [] + # The peak is not part of the TLV. + assert hksv3.tiers_from_sensor(sensor)[0].encode() == bytes.fromhex(TIER_HEX) + + +def test_tiers_are_frozen() -> None: + """Test a tier cannot change after it was advertised.""" + with pytest.raises(AttributeError): + hksv3.DEFAULT_AUDIO_TIER.target_bitrate = 1 # ty: ignore[invalid-assignment] + + +@pytest.mark.parametrize( + "tier", + [ + AudioStreamTier(1, 24000, AudioSampleRate.KHZ_48, packet_time=40), + AudioStreamTier(1, 24000, AudioSampleRate.KHZ_48, channels=2), + AudioStreamTier(1, 24000, 5), + AudioStreamTier(1, 24000, AudioSampleRate.KHZ_48, bit_depth=4), + AudioStreamTier(1, 0, AudioSampleRate.KHZ_48), + ], +) +def test_check_audio_tier_invalid(tier: AudioStreamTier) -> None: + """Test audio tiers the guide does not allow raise.""" + with pytest.raises(ValueError): + hksv3.check_audio_tier(tier) + + +def test_check_audio_tier_default() -> None: + """Test the default audio tier is Opus at 48 kHz as the guide reports it.""" + hksv3.check_audio_tier(hksv3.DEFAULT_AUDIO_TIER) + assert hksv3.DEFAULT_AUDIO_TIER.sample_rate == AudioSampleRate.KHZ_48 + + +def _sensor(*streams: tuple[int, int, int, int, int]) -> SensorConfiguration: + """A sensor with (quality, width, height, average, peak) encodings.""" + return SensorConfiguration( + UUID(int=0), + 3840, + 2160, + video_streams=[ + VideoStreamCapability( + UUID(int=index), quality, width, height, 30, avg, peak + ) + for index, (quality, width, height, avg, peak) in enumerate(streams) + ], + ) + + +GUIDE_4K = (2, 3840, 2160, 4500, 5000) +GUIDE_1080 = (3, 1920, 1080, 1700, 1800) +GUIDE_360 = (4, 640, 360, 180, 190) + + +def test_check_video_tiers_guide_table() -> None: + """Test the guide's own bit rates give no warning.""" + assert hksv3.check_video_tiers(_sensor(GUIDE_4K, GUIDE_1080, GUIDE_360)) == [] + + +@pytest.mark.parametrize( + ("stream", "warning"), + [ + ((3, 1920, 1080, 1500, 1800), "average more than 10 % below the peak"), + ((3, 1920, 1080, 1800, 1900), "peak above the guide's 1800 kbps"), + ((3, 1080, 1920, 1800, 1900), "1080x1920@30: peak above the guide's 1800"), + ((3, 1280, 600, 900, 950), "1280x600@30: peak above the guide's 800 kbps"), + ((3, 7680, 4320, 9000, 9500), "larger than the guide's 4K bit rate table"), + ], +) +def test_check_video_tiers_warnings( + stream: tuple[int, int, int, int, int], warning: str +) -> None: + """Test what the guide recommends only warns.""" + (found,) = hksv3.check_video_tiers(_sensor(GUIDE_4K, stream, GUIDE_360)) + assert warning in found + + +@pytest.mark.parametrize( + ("streams", "match"), + [ + ((GUIDE_1080, GUIDE_360, (5, 640, 360, 180, 190)), "Unknown video quality"), + (((1, 3840, 2160, 4500, 5000), GUIDE_1080, GUIDE_360), "needs a High"), + ((GUIDE_4K, (3, 1920, 1080, 1700, 1600), GUIDE_360), "below the average"), + ((), "at least one video encoding"), + ], +) +def test_check_video_tiers_invalid( + streams: tuple[tuple[int, int, int, int, int], ...], match: str +) -> None: + """Test qualities and bit rates the guide does not allow raise.""" + with pytest.raises(ValueError, match=match): + hksv3.check_video_tiers(_sensor(*streams)) + + +ADDRESS = EndpointAddress("192.168.1.114", False, 50000, 50002) +# 192.168.1.114, IPv4, video port 50000 and audio port 50002. +ADDRESS_HEX = "010100 020d3139322e3136382e312e313134 030250c3 040252c3" +SRTP = SRTPParameters(SRTPCryptoSuite.AES_CM_128_HMAC_SHA1_80, bytes(16), bytes(14)) + + +def test_setup_endpoints_golden() -> None: + """Test a Setup Endpoints write byte for byte and back.""" + request = SetupEndpoints(SESSION, ADDRESS, SRTP, SRTP) + srtp_hex = "010100 0210" + "00" * 16 + "030e" + "00" * 14 + encoded = request.encode() + + srtp_items = "0425" + srtp_hex + "0525" + srtp_hex + assert encoded == bytes.fromhex( + "0110" + SESSION.hex + "031a" + ADDRESS_HEX + srtp_items + ) + assert SetupEndpoints.decode(encoded) == request + assert "master_key" not in repr(request) + + +def test_setup_endpoints_response() -> None: + """Test a full answer and a status-only one encode and decode.""" + answer = SetupEndpointsResponse( + SESSION, SetupEndpointsStatus.SUCCESS, ADDRESS, SRTP, SRTP, 1, 0xDEADBEEF + ) + encoded = answer.encode() + + assert reference_parse(encoded)[5:] == [ + (6, b"\x01\x00\x00\x00"), + (7, b"\xef\xbe\xad\xde"), + ] + assert SetupEndpointsResponse.decode(encoded) == answer + assert SetupEndpointsResponse(None, SetupEndpointsStatus.ERROR).encode() == ( + b"\x02\x01\x02" + ) + + +@pytest.mark.parametrize( + "data", + [ + "0110" + SESSION.hex, + "0110" + SESSION.hex + "0300 0400 0500", + "0110" + SESSION.hex + "030e 010100 0201ff 030250c3 040252c3 0400 0500", + "0110" + SESSION.hex + "030d 010100 020131 030150 040252c3 0400 0500", + ], + ids=["missing", "empty", "not-utf-8", "short-port"], +) +def test_setup_endpoints_invalid(data: str) -> None: + """Test missing fields, bad text and bad ports raise.""" + with pytest.raises(ValueError): + SetupEndpoints.decode(bytes.fromhex(data)) From e7e0de4d780724063234120fa6d2e65a6f018ce3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:31:43 +0100 Subject: [PATCH 26/56] Add TLV8 codecs for the HKSV 17.99 WebRTC stream management The Camera WebRTC Stream Management service of the HomeKit Secure Video 17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through write response characteristics: the accessory offers, the controller answers, renegotiates, updates SFrame keys and ends the session. Add the offer options, ICE candidates and SFrame key data, the requests and responses of each characteristic and their status enums. Each type encodes and decodes, so tests can play the controller. WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend returns. With IceCandidate and SFrameKey they are the types the HAP side and the media share, all frozen. The offer's SDP carries the ICE candidates, so the Solicit Offer answer lists none, as in the Scrypted HomeKit plugin. generate_sframe_key() makes a key to send with. An answer or reoffer with an SDP above 32 KiB, a write with more than 64 ICE candidates or one above 1 KiB, and an Update Session with more than 16 keys to add or to remove do not decode, so the media side never sees one. --- pyhap/hksv3.py | 385 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_hksv3.py | 337 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 722 insertions(+) diff --git a/pyhap/hksv3.py b/pyhap/hksv3.py index e972ce49..77b470ab 100644 --- a/pyhap/hksv3.py +++ b/pyhap/hksv3.py @@ -14,6 +14,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field from enum import IntEnum +import secrets from typing import TypeVar from uuid import UUID @@ -713,3 +714,387 @@ def check_video_tiers(sensor: SensorConfiguration) -> list[str]: if stream.peak_bitrate > guide[1]: warnings.append(f"{name}: peak above the guide's {guide[1]} kbps") return warnings + + +class WebRTCSolicitOfferStatus(IntEnum): + SUCCESS = 0 + PRIVACY_MODE_ACTIVE = 1 + ERROR = 2 + + +class WebRTCStreamingStatus(IntEnum): + SUCCESS = 0 + UNKNOWN_SESSION = 1 + BUSY = 2 + ERROR = 3 + + +class WebRTCStreamingCommand(IntEnum): + END = 1 + + +# Limits of what a controller may send: the SDP of an answer or reoffer in +# bytes, the ICE candidates of a write and the bytes of one, and the SFrame +# keys an Update Session adds or removes. Larger writes do not decode. +MAX_SDP_SIZE = 32 * 1024 +MAX_ICE_CANDIDATES = 64 +MAX_ICE_CANDIDATE_SIZE = 1024 +MAX_SFRAME_KEY_CHANGES = 16 + + +def _to_str(data: bytes) -> str: + # A UnicodeDecodeError is a ValueError. + return data.decode("utf-8") + + +def _to_sdp(data: bytes) -> str: + if len(data) > MAX_SDP_SIZE: + raise ValueError(f"SDP of {len(data)} bytes, at most {MAX_SDP_SIZE}") + return _to_str(data) + + +def _encode_str(value: str | None) -> bytes | None: + return None if value is None else value.encode("utf-8") + + +def _session_id(session_id: UUID | None) -> bytes: + return b"" if session_id is None else encode_item(1, session_id.bytes) + + +@dataclass +class WebRTCOfferOptions: + """The WebRTC Offer Options of a Solicit Offer or Reoffer write. + + ``sframe_enabled`` is None if the controller left it out. + """ + + sframe_enabled: bool | None = None + + def encode(self) -> bytes: + if self.sframe_enabled is None: + return b"" + return encode_item(1, _uint(self.sframe_enabled, 1)) + + @classmethod + def decode(cls, data: bytes) -> WebRTCOfferOptions: + enabled = _optional_int(_fields(data), 1) + return cls(None if enabled is None else bool(enabled)) + + +def _options(fields: dict[int, list[bytes]], tag: int) -> WebRTCOfferOptions: + if tag not in fields: + return WebRTCOfferOptions() + return WebRTCOfferOptions.decode(fields[tag][0]) + + +@dataclass(frozen=True) +class IceCandidate: + """An ICE candidate as HAP carries it: RFC 8839 string, mid and m-line. + + One of the types shared with WebRTC media, see :class:`WebRTCOffer`. + """ + + candidate: str + sdp_mid: str | None = None + sdp_mline_index: int | None = None + + def encode(self) -> bytes: + index = self.sdp_mline_index + return ( + encode_item(1, self.candidate.encode("utf-8")) + + _optional_item(2, _encode_str(self.sdp_mid)) + + _optional_item(3, None if index is None else _uint(index, 2)) + ) + + @classmethod + def decode(cls, data: bytes) -> IceCandidate: + if len(data) > MAX_ICE_CANDIDATE_SIZE: + raise ValueError( + f"ICE candidate of {len(data)} bytes, at most {MAX_ICE_CANDIDATE_SIZE}" + ) + fields = _fields(data) + return cls( + _to_str(_required(fields, 1)), + _optional(fields, 2, _to_str), + _optional_int(fields, 3), + ) + + +def _encode_candidates(candidates: Iterable[IceCandidate]) -> bytes: + return encode_list(3, (candidate.encode() for candidate in candidates)) + + +def _candidates(fields: dict[int, list[bytes]]) -> list[IceCandidate]: + candidates = fields.get(3, []) + if len(candidates) > MAX_ICE_CANDIDATES: + raise ValueError( + f"{len(candidates)} ICE candidates, at most {MAX_ICE_CANDIDATES}" + ) + return [IceCandidate.decode(candidate) for candidate in candidates] + + +@dataclass(frozen=True) +class SFrameKey: + """SFrame Key Data: a key and its 64-bit key ID. + + One of the types shared with WebRTC media, see :class:`WebRTCOffer`. + """ + + key: bytes = field(repr=False) + kid: int + + def encode(self) -> bytes: + return encode_item(1, self.key) + encode_item(2, _uint(self.kid, 8)) + + @classmethod + def decode(cls, data: bytes) -> SFrameKey: + fields = _fields(data) + return cls(_required(fields, 1), _to_int(_required(fields, 2))) + + +def generate_sframe_key() -> SFrameKey: + """Make an SFrame key to send with: 32 random bytes and a random key ID.""" + return SFrameKey(secrets.token_bytes(32), secrets.randbits(64)) + + +@dataclass(frozen=True) +class WebRTCOffer: + """The accessory's offer for a new WebRTC session. + + This, :class:`WebRTCReofferAnswer`, :class:`IceCandidate` and + :class:`SFrameKey` are the types WebRTC media and the HAP side share. + + ``sdp`` carries all ICE candidates, HAP has no trickle ICE; the candidate + list of the Solicit Offer answer stays empty, as in the Scrypted HomeKit + plugin. ``sframe`` is the key the accessory sends with, see + :func:`generate_sframe_key`; None for a session without SFrame. + """ + + sdp: str + sframe: SFrameKey | None = None + + +@dataclass(frozen=True) +class WebRTCReofferAnswer: + """The accessory's answer to a controller's reoffer, see :class:`WebRTCOffer`. + + ``sframe`` is the key the accessory sends with from now on. + """ + + sdp: str + sframe: SFrameKey | None = None + + +def _encode_sframe(tag: int, sframe: SFrameKey | None) -> bytes: + return b"" if sframe is None else encode_item(tag, sframe.encode()) + + +@dataclass +class WebRTCSolicitOffer: + """A write to WebRTC Solicit Offer, the controller asks for an offer.""" + + options: WebRTCOfferOptions = field(default_factory=WebRTCOfferOptions) + + def encode(self) -> bytes: + return encode_item(1, self.options.encode()) + + @classmethod + def decode(cls, data: bytes) -> WebRTCSolicitOffer: + return cls(_options(_fields(data), 1)) + + +@dataclass +class WebRTCSolicitOfferResponse: + """The read back value of WebRTC Solicit Offer. + + Only a successful one has a session, an offer and candidates. + """ + + status: WebRTCSolicitOfferStatus | int + session_id: UUID | None = None + sdp_offer: str | None = None + candidates: list[IceCandidate] = field(default_factory=list) + sframe: SFrameKey | None = None + + def encode(self) -> bytes: + return b"".join( + ( + _session_id(self.session_id), + _optional_item(2, _encode_str(self.sdp_offer)), + _encode_candidates(self.candidates), + encode_item(4, _uint(self.status, 1)), + _encode_sframe(5, self.sframe), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCSolicitOfferResponse: + fields = _fields(data) + return cls( + _to_enum(WebRTCSolicitOfferStatus, _required(fields, 4)), + _optional(fields, 1, _to_uuid), + _optional(fields, 2, _to_str), + _candidates(fields), + _optional(fields, 5, SFrameKey.decode), + ) + + +@dataclass +class WebRTCProvideAnswer: + """A write to WebRTC Provide Answer.""" + + session_id: UUID + sdp_answer: str + candidates: list[IceCandidate] = field(default_factory=list) + + def encode(self) -> bytes: + return ( + encode_item(1, self.session_id.bytes) + + encode_item(2, self.sdp_answer.encode("utf-8")) + + _encode_candidates(self.candidates) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCProvideAnswer: + fields = _fields(data) + return cls( + _to_uuid(_required(fields, 1)), + _to_sdp(_required(fields, 2)), + _candidates(fields), + ) + + +@dataclass +class WebRTCSessionStatus: + """The read back value of Provide Answer, Streaming Control and Update Session. + + Without a session, e.g. for a write that did not decode, only the status + is sent. + """ + + session_id: UUID | None + status: WebRTCStreamingStatus | int + + def encode(self) -> bytes: + return _session_id(self.session_id) + encode_item(2, _uint(self.status, 1)) + + @classmethod + def decode(cls, data: bytes) -> WebRTCSessionStatus: + fields = _fields(data) + return cls( + _optional(fields, 1, _to_uuid), + _to_enum(WebRTCStreamingStatus, _required(fields, 2)), + ) + + +@dataclass +class WebRTCStreamingControl: + """A write to WebRTC Streaming Control.""" + + session_id: UUID + command: WebRTCStreamingCommand | int + + def encode(self) -> bytes: + return encode_item(1, self.session_id.bytes) + encode_item( + 2, _uint(self.command, 1) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCStreamingControl: + fields = _fields(data) + return cls( + _to_uuid(_required(fields, 1)), + _to_enum(WebRTCStreamingCommand, _required(fields, 2)), + ) + + +@dataclass +class WebRTCReoffer: + """A write to WebRTC Reoffer, the controller renegotiates with an offer.""" + + session_id: UUID + sdp_offer: str + options: WebRTCOfferOptions = field(default_factory=WebRTCOfferOptions) + + def encode(self) -> bytes: + return ( + encode_item(1, self.session_id.bytes) + + encode_item(2, self.sdp_offer.encode("utf-8")) + + encode_item(3, self.options.encode()) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCReoffer: + fields = _fields(data) + return cls( + _to_uuid(_required(fields, 1)), + _to_sdp(_required(fields, 2)), + _options(fields, 3), + ) + + +@dataclass +class WebRTCReofferResponse: + """The read back value of WebRTC Reoffer. + + Only a successful one has an answer. + """ + + session_id: UUID | None + status: WebRTCStreamingStatus | int + sdp_answer: str | None = None + sframe: SFrameKey | None = None + + def encode(self) -> bytes: + return b"".join( + ( + _session_id(self.session_id), + _optional_item(2, _encode_str(self.sdp_answer)), + encode_item(3, _uint(self.status, 1)), + _encode_sframe(4, self.sframe), + ) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCReofferResponse: + fields = _fields(data) + return cls( + _optional(fields, 1, _to_uuid), + _to_enum(WebRTCStreamingStatus, _required(fields, 3)), + _optional(fields, 2, _to_str), + _optional(fields, 4, SFrameKey.decode), + ) + + +@dataclass +class WebRTCUpdateSession: + """A write to WebRTC Update Session, the SFrame keys the controller sends with.""" + + session_id: UUID + add_keys: list[SFrameKey] = field(default_factory=list) + remove_kids: list[int] = field(default_factory=list) + + def encode(self) -> bytes: + return ( + encode_item(1, self.session_id.bytes) + + encode_list(2, (key.encode() for key in self.add_keys)) + + encode_list( + 3, (encode_item(1, _uint(kid, 8)) for kid in self.remove_kids) + ) + ) + + @classmethod + def decode(cls, data: bytes) -> WebRTCUpdateSession: + fields = _fields(data) + keys = fields.get(2, []) + kids = fields.get(3, []) + if max(len(keys), len(kids)) > MAX_SFRAME_KEY_CHANGES: + raise ValueError( + f"{len(keys)} keys to add and {len(kids)} to remove," + f" at most {MAX_SFRAME_KEY_CHANGES} each" + ) + return cls( + _to_uuid(_required(fields, 1)), + [SFrameKey.decode(key) for key in keys], + [_to_int(_required(_fields(kid), 1)) for kid in kids], + ) diff --git a/tests/test_hksv3.py b/tests/test_hksv3.py index e9c4e69a..461b9005 100644 --- a/tests/test_hksv3.py +++ b/tests/test_hksv3.py @@ -1,5 +1,6 @@ """Tests for pyhap.hksv3.""" +from collections.abc import Callable import random from uuid import UUID @@ -14,6 +15,7 @@ CameraCapabilities, CameraZones, EndpointAddress, + IceCandidate, RTPStreamingCommand, RTPStreamingControl, RTPStreamingStatus, @@ -23,6 +25,7 @@ SetupEndpoints, SetupEndpointsResponse, SetupEndpointsStatus, + SFrameKey, SRTPCryptoSuite, SRTPParameters, SupportedAudioStreamTiers, @@ -31,6 +34,18 @@ VideoQuality, VideoStreamCapability, VideoStreamTier, + WebRTCOfferOptions, + WebRTCProvideAnswer, + WebRTCReoffer, + WebRTCReofferResponse, + WebRTCSessionStatus, + WebRTCSolicitOffer, + WebRTCSolicitOfferResponse, + WebRTCSolicitOfferStatus, + WebRTCStreamingCommand, + WebRTCStreamingControl, + WebRTCStreamingStatus, + WebRTCUpdateSession, ZoneData, ZoneMethod, ZonePolygon, @@ -651,3 +666,325 @@ def test_setup_endpoints_invalid(data: str) -> None: """Test missing fields, bad text and bad ports raise.""" with pytest.raises(ValueError): SetupEndpoints.decode(bytes.fromhex(data)) + + +def _item(tag: int, value: bytes) -> str: + """Hand encode one TLV of up to 255 bytes as hex.""" + return f"{tag:02x}{len(value):02x}{value.hex()}" + + +HOST = b"candidate:1 1 udp 2122260223 192.0.2.10 50000 typ host" +SRFLX = ( + b"candidate:2 1 udp 1686052607 198.51.100.7 50001 typ srflx" + b" raddr 192.0.2.10 rport 50000" +) +# The host candidate for the first m-line, mid "0", and one without either. +HOST_HEX = _item(1, HOST) + "020130 03020000" +SRFLX_HEX = _item(1, SRFLX) +CANDIDATES = [ + IceCandidate(HOST.decode(), "0", 0), + IceCandidate(SRFLX.decode()), +] +SFRAME = SFrameKey(bytes(range(32)), 0x0102030405060708) +SFRAME_HEX = _item(1, bytes(range(32))) + "02080807060504030201" + + +@pytest.mark.parametrize( + ("data", "enabled"), + [("0103 010101", True), ("0103 010100", False), ("0100", None), ("", None)], +) +def test_solicit_offer_decode(data: str, enabled: bool | None) -> None: + """Test SFrame Enabled, an empty and a missing Options TLV.""" + request = WebRTCSolicitOffer.decode(bytes.fromhex(data)) + assert request == WebRTCSolicitOffer(WebRTCOfferOptions(enabled)) + + +def test_solicit_offer_round_trip() -> None: + """Test the encoding a controller sends, unknown types are skipped.""" + request = WebRTCSolicitOffer(WebRTCOfferOptions(True)) + assert request.encode() == bytes.fromhex("0103 010101") + raw = bytes.fromhex("0106 010101 0901aa 0a00") + assert WebRTCSolicitOffer.decode(raw) == request + + +def test_solicit_offer_response_golden() -> None: + """Test a successful response with two candidates and an SFrame key.""" + response = WebRTCSolicitOfferResponse( + WebRTCSolicitOfferStatus.SUCCESS, SESSION, "v=0\r\n", CANDIDATES, SFRAME + ) + encoded = response.encode() + assert encoded == bytes.fromhex( + "0110" + + SESSION.hex + + "0205 763d300d0a" + + _item(3, bytes.fromhex(HOST_HEX)) + + "0000" + + _item(3, bytes.fromhex(SRFLX_HEX)) + + "040100" + + _item(5, bytes.fromhex(SFRAME_HEX)) + ) + top = reference_parse(encoded) + assert top[2:4] == [ + (3, bytes.fromhex(HOST_HEX)), + (3, bytes.fromhex(SRFLX_HEX)), + ] + assert reference_parse(top[2][1]) == [(1, HOST), (2, b"0"), (3, b"\x00\x00")] + assert reference_parse(top[5][1]) == [ + (1, bytes(range(32))), + (2, bytes.fromhex("0807060504030201")), + ] + assert WebRTCSolicitOfferResponse.decode(encoded) == response + + +@pytest.mark.parametrize( + ("status", "data"), + [ + (WebRTCSolicitOfferStatus.PRIVACY_MODE_ACTIVE, "040101"), + (WebRTCSolicitOfferStatus.ERROR, "040102"), + ], +) +def test_solicit_offer_response_failed( + status: WebRTCSolicitOfferStatus, data: str +) -> None: + """Test a failed response has only the status.""" + assert WebRTCSolicitOfferResponse(status).encode() == bytes.fromhex(data) + + +@pytest.mark.parametrize("length", [255, 300, 600]) +def test_solicit_offer_response_long_sdp(length: int) -> None: + """Test an SDP offer over 255 bytes is fragmented, then the candidates.""" + sdp = ("v=0\r\n" + "a=x" * length)[:length] + response = WebRTCSolicitOfferResponse(0, SESSION, sdp, CANDIDATES) + encoded = response.encode() + assert encoded[18:20] == b"\x02\xff" + top = reference_parse(encoded) + assert top[1] == (2, sdp.encode()) + assert top[2] == (3, bytes.fromhex(HOST_HEX)) + assert WebRTCSolicitOfferResponse.decode(encoded) == response + + +def test_provide_answer_golden() -> None: + """Test decoding an answer with two candidates.""" + raw = bytes.fromhex( + "0110" + + SESSION.hex + + "0205 763d300d0a" + + _item(3, bytes.fromhex(SRFLX_HEX)) + + "0000" + + _item(3, bytes.fromhex(HOST_HEX)) + ) + request = WebRTCProvideAnswer.decode(raw) + assert request == WebRTCProvideAnswer(SESSION, "v=0\r\n", CANDIDATES[::-1]) + assert request.encode() == raw + assert reference_parse(raw)[2:] == [ + (3, bytes.fromhex(SRFLX_HEX)), + (3, bytes.fromhex(HOST_HEX)), + ] + + +def test_session_status() -> None: + """Test the read back status with and without a session.""" + encoded = WebRTCSessionStatus(SESSION, WebRTCStreamingStatus.BUSY).encode() + assert encoded == bytes.fromhex("0110" + SESSION.hex + "020102") + assert reference_parse(encoded) == [(1, SESSION.bytes), (2, b"\x02")] + error = WebRTCSessionStatus(None, WebRTCStreamingStatus.ERROR) + assert error.encode() == b"\x02\x01\x03" + assert WebRTCSessionStatus.decode(b"\x02\x01\x03") == error + + +def test_streaming_control() -> None: + """Test an End, and an unknown command kept as int.""" + raw = bytes.fromhex("0110" + SESSION.hex + "020101") + request = WebRTCStreamingControl.decode(raw) + assert request == WebRTCStreamingControl(SESSION, WebRTCStreamingCommand.END) + assert request.encode() == raw + unknown = WebRTCStreamingControl.decode( + bytes.fromhex("0110" + SESSION.hex + "020107 0901aa") + ) + assert unknown.command == 7 + assert not isinstance(unknown.command, WebRTCStreamingCommand) + + +def test_reoffer_golden() -> None: + """Test a reoffer with and without options.""" + raw = bytes.fromhex("0110" + SESSION.hex + "0205 763d300d0a 0303 010100") + request = WebRTCReoffer.decode(raw) + assert request == WebRTCReoffer(SESSION, "v=0\r\n", WebRTCOfferOptions(False)) + assert request.encode() == raw + plain = WebRTCReoffer.decode(bytes.fromhex("0110" + SESSION.hex + "0200")) + assert plain == WebRTCReoffer(SESSION, "") + assert plain.options.sframe_enabled is None + + +def test_reoffer_response_golden() -> None: + """Test a successful reoffer response, and a failed one.""" + response = WebRTCReofferResponse(SESSION, 0, "v=0\r\n", SFRAME) + encoded = response.encode() + assert encoded == bytes.fromhex( + "0110" + + SESSION.hex + + "0205 763d300d0a 030100" + + _item(4, bytes.fromhex(SFRAME_HEX)) + ) + assert WebRTCReofferResponse.decode(encoded) == response + failed = WebRTCReofferResponse(SESSION, WebRTCStreamingStatus.UNKNOWN_SESSION) + assert failed.encode() == bytes.fromhex("0110" + SESSION.hex + "030101") + + +def test_update_session_golden() -> None: + """Test keys to add and key IDs to remove are list items.""" + other = SFrameKey(b"\xaa" * 16, 9) + other_hex = _item(1, b"\xaa" * 16) + "02080900000000000000" + raw = bytes.fromhex( + "0110" + + SESSION.hex + + _item(2, bytes.fromhex(SFRAME_HEX)) + + "0000" + + _item(2, bytes.fromhex(other_hex)) + + "030a 0108 0807060504030201 0000 030a 0108 0200000000000000" + ) + request = WebRTCUpdateSession.decode(raw) + assert request == WebRTCUpdateSession( + SESSION, [SFRAME, other], [0x0102030405060708, 2] + ) + assert request.encode() == raw + top = reference_parse(raw) + assert len(top) == 5 + assert top[1] == (2, bytes.fromhex(SFRAME_HEX)) + assert reference_parse(top[2][1]) == [(1, b"\xaa" * 16), (2, b"\x09" + bytes(7))] + assert top[3][0] == top[4][0] == 3 + assert reference_parse(top[4][1]) == [(1, b"\x02" + bytes(7))] + + +def test_update_session_short_and_empty() -> None: + """Test short key IDs are accepted and lists may be empty.""" + request = WebRTCUpdateSession.decode( + bytes.fromhex("0110" + SESSION.hex + "0303 010105") + ) + assert request == WebRTCUpdateSession(SESSION, [], [5]) + empty = WebRTCUpdateSession(SESSION) + assert empty.encode() == bytes.fromhex("0110" + SESSION.hex) + assert WebRTCUpdateSession.decode(empty.encode()) == empty + + +def test_webrtc_unknown_values() -> None: + """Test unknown statuses are kept as int and unknown types are skipped.""" + response = WebRTCSolicitOfferResponse.decode( + bytes.fromhex("040109 0901aa 0306 010161 0a01ff") + ) + assert response == WebRTCSolicitOfferResponse(9, candidates=[IceCandidate("a")]) + status = WebRTCSessionStatus.decode(bytes.fromhex("020107")) + assert status.status == 7 + assert not isinstance(status.status, WebRTCStreamingStatus) + assert repr(SFRAME) == f"SFrameKey(kid={SFRAME.kid})" + + +@pytest.mark.parametrize( + ("decode", "data"), + [ + # Missing session, bad session length, missing SDP. + (WebRTCProvideAnswer.decode, "0205 763d300d0a"), + (WebRTCProvideAnswer.decode, "0104aabbccdd 0200"), + (WebRTCReoffer.decode, "0110" + SESSION.hex), + # A candidate without its string, an SDP that is not UTF-8. + (WebRTCProvideAnswer.decode, "0110" + SESSION.hex + "0200 0303 020130"), + (WebRTCProvideAnswer.decode, "0110" + SESSION.hex + "0201ff"), + # A key ID over 64 bits, a key without its ID. + (WebRTCUpdateSession.decode, "0110" + SESSION.hex + "030b 0109" + "00" * 9), + (WebRTCUpdateSession.decode, "0110" + SESSION.hex + "0203 0101aa"), + (WebRTCStreamingControl.decode, "0110" + SESSION.hex), + (WebRTCSolicitOffer.decode, "0103 0101"), + (WebRTCSolicitOfferResponse.decode, "0110" + SESSION.hex), + ], +) +def test_webrtc_invalid(decode: Callable[[bytes], object], data: str) -> None: + """Test malformed writes raise ValueError.""" + with pytest.raises(ValueError): + decode(bytes.fromhex(data)) + + +def test_generate_sframe_key() -> None: + """Test generated keys have 32 random bytes and a 64-bit key ID.""" + first = hksv3.generate_sframe_key() + second = hksv3.generate_sframe_key() + assert len(first.key) == 32 + assert first.key != second.key + assert 0 <= first.kid < 2**64 + assert SFrameKey.decode(first.encode()) == first + assert first.key.hex() not in repr(first) + + +def test_shared_webrtc_types_are_frozen() -> None: + """Test the offer and answer types the media side gets cannot change.""" + offer = hksv3.WebRTCOffer("v=0\r\n", hksv3.generate_sframe_key()) + with pytest.raises(AttributeError): + offer.sdp = "" # ty: ignore[invalid-assignment] + assert hksv3.WebRTCReofferAnswer("v=0\r\n").sframe is None + + +LIMIT_CANDIDATE = IceCandidate("candidate:1 1 udp 1 192.168.1.2 50000 typ host") +MAX_SDP = "a" * hksv3.MAX_SDP_SIZE +LONG_CANDIDATE = IceCandidate("a" * (hksv3.MAX_ICE_CANDIDATE_SIZE - 32), "0", 0) +OVERSIZED_CANDIDATE = IceCandidate("a" * hksv3.MAX_ICE_CANDIDATE_SIZE, "0", 0) +LIMIT_KEYS = [SFRAME] * hksv3.MAX_SFRAME_KEY_CHANGES +LIMIT_KIDS = [5] * hksv3.MAX_SFRAME_KEY_CHANGES + + +def test_webrtc_size_limits_accepted() -> None: + """Test writes at the SDP, candidate and key limits decode.""" + answer = WebRTCProvideAnswer( + SESSION, MAX_SDP, [LIMIT_CANDIDATE] * hksv3.MAX_ICE_CANDIDATES + ) + assert WebRTCProvideAnswer.decode(answer.encode()) == answer + assert WebRTCReoffer.decode(WebRTCReoffer(SESSION, MAX_SDP).encode()).sdp_offer + assert len(LONG_CANDIDATE.encode()) <= hksv3.MAX_ICE_CANDIDATE_SIZE + assert IceCandidate.decode(LONG_CANDIDATE.encode()) == LONG_CANDIDATE + update = WebRTCUpdateSession(SESSION, LIMIT_KEYS, LIMIT_KIDS) + assert WebRTCUpdateSession.decode(update.encode()) == update + + +@pytest.mark.parametrize( + ("decode", "request_value"), + [ + (WebRTCProvideAnswer.decode, WebRTCProvideAnswer(SESSION, MAX_SDP + "a")), + ( + WebRTCProvideAnswer.decode, + WebRTCProvideAnswer( + SESSION, "v=0", [LIMIT_CANDIDATE] * (hksv3.MAX_ICE_CANDIDATES + 1) + ), + ), + (WebRTCReoffer.decode, WebRTCReoffer(SESSION, MAX_SDP + "a")), + ( + WebRTCProvideAnswer.decode, + WebRTCProvideAnswer(SESSION, "v=0", [OVERSIZED_CANDIDATE]), + ), + (IceCandidate.decode, OVERSIZED_CANDIDATE), + ( + WebRTCUpdateSession.decode, + WebRTCUpdateSession(SESSION, [*LIMIT_KEYS, SFRAME]), + ), + ( + WebRTCUpdateSession.decode, + WebRTCUpdateSession(SESSION, [], [*LIMIT_KIDS, 5]), + ), + ], + ids=[ + "answer-sdp", + "answer-candidates", + "reoffer-sdp", + "answer-candidate-size", + "candidate-size", + "update-add-keys", + "update-remove-kids", + ], +) +def test_webrtc_size_limits_refused( + decode: Callable[[bytes], object], + request_value: WebRTCProvideAnswer + | WebRTCReoffer + | IceCandidate + | WebRTCUpdateSession, +) -> None: + """Test writes above the SDP, candidate or key limits do not decode.""" + with pytest.raises(ValueError, match="at most"): + decode(request_value.encode()) From e445c4a45fc1a935452e9a8b0f94b4fdaffe0de3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:40:17 +0100 Subject: [PATCH 27/56] Add the session state machine of the HKSV 17.99 stream services The multi-tier RTP and WebRTC stream services of the HomeKit Secure Video 17.99 guide keep sessions that a controller sets up and the camera streams through a delegate. Both need the same lifecycle, so it lives in one place, pyhap.camera_stream, which the services in the next changes build on. A session goes from new to prepared or solicited, through answering for WebRTC, to started or active, and ends from any state. Only the connection that set it up may control it, and no two sessions of a controller share an ID. A controller has a limit of sessions in all and per connection. A delegate hook runs as a task the session owns. The HAP request waits for it until one deadline, hook_timeout after the write, which must leave a second of the 9 second HAP response timeout. A hook that is still running then is cancelled and its session ends. Each session the delegate saw is released exactly once, in the background and after its cancelled hooks finished, or after hook_timeout if one ignores its cancellation. So a timeout never waits for a second hook inside the request. Timers re-check the state before they end a session. A delegate fails a request with StreamError(status) and ends a session on its own with session.end(reason). It keeps its state by session object, since a new session may reuse the ID of one still being released. The integrator ends sessions with end_session() and end_all_sessions(). close() ends everything, waits for the background tasks and takes no new session afterwards. --- pyhap/camera_stream.py | 434 ++++++++++++++++++++++++++++++++ tests/conftest.py | 64 +++++ tests/test_camera_stream.py | 481 ++++++++++++++++++++++++++++++++++++ 3 files changed, 979 insertions(+) create mode 100644 pyhap/camera_stream.py create mode 100644 tests/test_camera_stream.py diff --git a/pyhap/camera_stream.py b/pyhap/camera_stream.py new file mode 100644 index 00000000..24701593 --- /dev/null +++ b/pyhap/camera_stream.py @@ -0,0 +1,434 @@ +"""Stream sessions of the HomeKit Secure Video 17.99 camera services. + +Developer Preview: HomeKit Secure Video 17.99 is a preview of the guide, so +this API is experimental and may change. + +A :class:`StreamController` keeps the sessions of one stream management +service of one sensor, the multi-tier RTP or the WebRTC one. The media comes +from a delegate, see :class:`pyhap.camera_multi_tier.MultiTierDelegate` and +:class:`pyhap.camera_webrtc.WebRTCDelegate`. Every session runs through one +state machine:: + + NEW -> PREPARED (multi-tier) or SOLICITED (WebRTC) + -> ANSWERING (WebRTC, while the answer is applied) + -> STARTED (multi-tier) or ACTIVE (WebRTC) + -> ENDED, from any state + +A delegate hook runs as a task the session owns. A HAP request waits for it +until one deadline, ``hook_timeout`` after the write, which leaves time to +answer within the HAP response timeout. A hook that takes longer is +cancelled and its session ends. Once a session ended, the delegate releases +it exactly once, in the background, if it saw the session at all. + +A delegate keeps its state by session object, e.g. in ``session.data``, not +by session ID: a controller may set up a session with the ID of one that is +still being released. The delegate ends a session on its side, e.g. when +its media stopped, with :meth:`StreamSession.end`. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine, Mapping +from dataclasses import dataclass, field, fields +from enum import Enum, IntEnum +import logging +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from uuid import UUID + +from pyhap import hksv3 +from pyhap.hap_handler import RESPONSE_TIMEOUT + +if TYPE_CHECKING: + from pyhap.camera import Camera + +__all__ = [ + "DEFAULT_HOOK_TIMEOUT", + "MAX_HOOK_TIMEOUT", + "SessionState", + "StreamController", + "StreamError", + "StreamSession", +] + +logger = logging.getLogger(__name__) + +# Seconds a request keeps after its hooks to answer within RESPONSE_TIMEOUT. +HOOK_TIMEOUT_MARGIN = 1.0 +MAX_HOOK_TIMEOUT = RESPONSE_TIMEOUT - HOOK_TIMEOUT_MARGIN +DEFAULT_HOOK_TIMEOUT = MAX_HOOK_TIMEOUT + +_S = TypeVar("_S", bound="StreamSession") +_T = TypeVar("_T") +_C = TypeVar("_C") +_E = TypeVar("_E", bound=IntEnum) + + +class StreamError(Exception): + """Raised by a delegate hook to answer the request with ``status``. + + ``status`` is one of the request's answer, e.g. + :attr:`pyhap.hksv3.RTPStreamingStatus.BUSY`. Other exceptions, and hooks + that take too long, answer with the request's error status. + """ + + def __init__(self, status: int, message: str = "") -> None: + super().__init__(message or f"status {status}") + self.status = status + + +class SessionState(Enum): + NEW = "new" + PREPARED = "prepared" + SOLICITED = "solicited" + ANSWERING = "answering" + STARTED = "started" + ACTIVE = "active" + ENDED = "ended" + + +@dataclass(eq=False, kw_only=True) +class StreamSession: + """A stream session of one controller connection and one sensor. + + Only ``client_addr``, the connection that set the session up, may control + it. ``data`` is free for the delegate. + """ + + id: UUID + sensor: hksv3.SensorConfiguration + client_addr: tuple[str, int] | None + state: SessionState = SessionState.NEW + data: dict[str, Any] = field(default_factory=dict) + _tasks: set[asyncio.Task[Any]] = field(default_factory=set, init=False, repr=False) + _timer: asyncio.TimerHandle | None = field(default=None, init=False, repr=False) + _delegated: bool = field(default=False, init=False, repr=False) + _release: asyncio.Task[bool] | None = field(default=None, init=False, repr=False) + _controller: StreamController[Any] | None = field( + default=None, init=False, repr=False + ) + + @property + def sensor_uuid(self) -> UUID: + return self.sensor.sensor_uuid + + @property + def ended(self) -> bool: + return self.state is SessionState.ENDED + + @property + def busy(self) -> bool: + """Whether a delegate hook of the session is running.""" + return bool(self._tasks) + + def end(self, reason: str = "the camera ended it") -> None: + """End the session on the camera's side, e.g. when its media stopped. + + Schedules the end on the event loop and returns at once; the + delegate then releases the session as for any other end. Does + nothing for a session that ended. Call it in the event loop. + """ + controller = self._controller + if controller is not None and not self.ended: + controller._loop.call_soon(controller._end, self, reason) + + +class HookFailed(Exception): + """A hook failed, ``status`` is the one the delegate asked for, if any. + + Raised by :meth:`StreamController._run_hook` to the stream services. + """ + + def __init__(self, status: int | None = None) -> None: + super().__init__(status) + self.status = status + + +def failure_status(status_type: type[_E], status: int | None, error: _E) -> _E: + """The status a failed request answers, ``error`` unless the delegate chose.""" + try: + chosen = status_type(status) + except ValueError: + return error + return chosen if chosen else error + + +def check_hook_timeout(hook_timeout: float) -> None: + """Make sure a hook leaves time to answer within the HAP response timeout.""" + if not 0 < hook_timeout <= MAX_HOOK_TIMEOUT: + raise ValueError(f"hook_timeout must be above 0 and at most {MAX_HOOK_TIMEOUT}") + + +def config_from_option(config_type: type[_C], value: Any, name: str) -> _C: + """Build a config dataclass from an option dict, or take it as it is. + + :raises ValueError: For an unknown key or a value of another type. + """ + if isinstance(value, config_type): + return value + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be a dict or a {config_type.__name__}") + known = {item.name for item in fields(cast(Any, config_type))} + if unknown := sorted(set(value) - known): + raise ValueError(f"Unknown {name} option: {', '.join(unknown)}") + return config_type(**value) + + +class StreamController(Generic[_S]): + """The sessions of one stream management service of one sensor.""" + + kind = "stream" + + def __init__( + self, + camera: Camera, + sensor: hksv3.SensorConfiguration, + hook_timeout: float, + max_sessions: int, + max_sessions_per_connection: int, + ) -> None: + check_hook_timeout(hook_timeout) + self.camera = camera + self.sensor = sensor + self.hook_timeout = hook_timeout + self.max_sessions = max_sessions + self.max_sessions_per_connection = max_sessions_per_connection + self._sessions: dict[UUID, _S] = {} + self._tasks: set[asyncio.Task[Any]] = set() + self._closed = False + + @property + def sessions(self) -> Mapping[UUID, _S]: + """The sessions that did not end, by ID.""" + return MappingProxyType(self._sessions) + + @property + def _loop(self) -> asyncio.AbstractEventLoop: + return cast(asyncio.AbstractEventLoop, self.camera.driver.loop) + + def _deadline(self) -> float: + """The deadline of the hooks of a request that arrives now.""" + return self._loop.time() + self.hook_timeout + + def _add(self, session: _S) -> None: + """Register a new session, it may end itself from now on. + + :raises RuntimeError: If another session has the same ID. + """ + if self._sessions.get(session.id, session) is not session: + raise RuntimeError(f"[{session.id}] Another session has the ID") + session._controller = self + self._sessions[session.id] = session + + def _no_room(self, client_addr: Any) -> str | None: + """Why a new session of ``client_addr`` does not fit, None if it does.""" + if self._closed: + return f"The {self.kind} service is closed" + if len(self._sessions) >= self.max_sessions: + return f"Too many {self.kind} sessions" + owned = sum( + session.client_addr == client_addr for session in self._sessions.values() + ) + if owned >= self.max_sessions_per_connection: + return f"Too many {self.kind} sessions of the connection" + return None + + def _owned(self, session_id: UUID, client_addr: Any) -> _S | None: + """Return the session if ``client_addr`` owns it. + + A session of another connection is unknown to it. + """ + session = self._sessions.get(session_id) + if session is not None and session.client_addr != client_addr: + logger.warning("[%s] Not the controller of the session", session_id) + return None + return session + + async def _run_hook( + self, + session: _S, + name: str, + hook: Coroutine[Any, Any, _T], + deadline: float, + end_on_failure: bool = False, + first: bool = False, + ) -> _T: + """Run a delegate hook as a task of ``session`` until ``deadline``. + + A hook that is still running then is cancelled and its session ends. + ``first`` marks the hook that introduces the session to the delegate, + which then releases it when it ends. + + :raises HookFailed: If the hook failed or took too long, or the + session ended meanwhile. + """ + if session.ended: + hook.close() + raise HookFailed + if first: + session._delegated = True + task = self._loop.create_task(hook) + session._tasks.add(task) + task.add_done_callback(session._tasks.discard) + try: + done, _ = await asyncio.wait( + {task}, timeout=max(deadline - self._loop.time(), 0) + ) + except asyncio.CancelledError: + # The request went away, e.g. its connection closed. + self._end(session, "the request was cancelled") + raise + if not done: + logger.warning("[%s] The %s hook timed out", session.id, name) + self._end(session, f"the {name} hook timed out") + raise HookFailed + if task.cancelled() or session.ended: + raise HookFailed + error = task.exception() + if error is None: + return task.result() + if isinstance(error, StreamError): + logger.warning("[%s] The %s hook failed: %s", session.id, name, error) + elif isinstance(error, NotImplementedError): + logger.warning("[%s] No %s hook, refusing", session.id, name) + else: + logger.error("[%s] The %s hook failed", session.id, name, exc_info=error) + if end_on_failure: + self._end(session, f"the {name} hook failed") + raise HookFailed(error.status if isinstance(error, StreamError) else None) + + def _background(self, coro: Coroutine[Any, Any, _T]) -> asyncio.Task[_T]: + task = self._loop.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + def _end(self, session: _S, reason: str) -> asyncio.Task[bool] | None: + """End ``session`` now and release it in the background. + + Cancels its timer and its running hooks. Returns the release, None if + the delegate never saw the session. Ending it again returns the same. + """ + if not session.ended: + previous = session.state + session.state = SessionState.ENDED + if self._sessions.get(session.id) is session: + del self._sessions[session.id] + self._cancel_timer(session) + hooks = list(session._tasks) + for hook in hooks: + hook.cancel() + logger.debug("[%s] %s session ended: %s", session.id, self.kind, reason) + self._session_ended(session, previous) + if session._delegated: + session._release = self._background(self._release(session, hooks)) + return session._release + + async def _release(self, session: _S, hooks: list[asyncio.Task[Any]]) -> bool: + if hooks: + # Cancelled hooks finish before the release starts, unless one + # ignores its cancellation. + _, pending = await asyncio.wait(hooks, timeout=self.hook_timeout) + if pending: + logger.warning( + "[%s] Releasing the session while %d cancelled hooks still run", + session.id, + len(pending), + ) + try: + await asyncio.wait_for(self._release_hook(session), self.hook_timeout) + except asyncio.TimeoutError: + logger.warning("[%s] Releasing the session timed out", session.id) + return False + # The delegate may fail in many ways, the session is gone anyway. + except Exception: + logger.exception("[%s] Releasing the session failed", session.id) + return False + return True + + async def _end_by_request(self, session: _S, deadline: float) -> bool: + """End a session for a request, waiting for its release until ``deadline``.""" + release = self._end(session, "the controller ended it") + if release is None: + return True + done, _ = await asyncio.wait( + {release}, timeout=max(deadline - self._loop.time(), 0) + ) + return bool(done) and release.result() + + def _set_timer( + self, session: _S, delay: float | None, reason: str, *states: SessionState + ) -> None: + """End ``session`` after ``delay`` seconds if it is still in ``states``.""" + self._cancel_timer(session) + if delay is not None: + session._timer = self._loop.call_later( + delay, self._expire, session, reason, states + ) + + @staticmethod + def _cancel_timer(session: StreamSession) -> None: + if session._timer is not None: + session._timer.cancel() + session._timer = None + + def _expire( + self, session: _S, reason: str, states: tuple[SessionState, ...] + ) -> None: + session._timer = None + if session.state in states: + logger.warning("[%s] %s, ending the session", session.id, reason) + self._end(session, reason) + + def _session_ended(self, session: _S, previous: SessionState) -> None: + """Called once when a session ended, before its release starts.""" + + async def _release_hook(self, session: _S) -> None: + """Let the delegate release ``session``.""" + raise NotImplementedError + + def _end_sessions(self, reason: str) -> None: + """End all sessions now, releasing them in the background.""" + for session in list(self._sessions.values()): + self._end(session, reason) + + async def end_session(self, session_id: UUID) -> bool: + """End a session, e.g. when its stream ended on the camera's side. + + Waits until the delegate released it. Returns False if there is no + such session. + """ + session = self._sessions.get(session_id) + if session is None: + return False + release = self._end(session, "the camera ended it") + if release is not None: + await asyncio.shield(release) + return True + + async def end_all_sessions(self) -> None: + """End all sessions and wait until the delegate released them.""" + releases = [ + self._end(session, "the camera ended it") + for session in list(self._sessions.values()) + ] + await asyncio.gather( + *(asyncio.shield(release) for release in releases if release is not None) + ) + + def client_disconnected(self, client_addr: tuple[str, int]) -> None: + """End the sessions of a connection that closed.""" + for session in list(self._sessions.values()): + if session.client_addr == client_addr: + self._end(session, "the controller disconnected") + + async def close(self) -> None: + """End all sessions and wait for every task, e.g. when the camera stops. + + No new session fits afterwards. + """ + self._closed = True + self._end_sessions("the camera stopped") + while pending := [task for task in self._tasks if not task.done()]: + await asyncio.wait(pending) diff --git a/tests/conftest.py b/tests/conftest.py index 7ad7edb1..cf5c7e7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,13 @@ from collections.abc import Callable, Iterator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID, uuid4 import pytest +from pyhap import hksv3 from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera_stream import StreamController, StreamSession from pyhap.loader import Loader @@ -60,3 +63,64 @@ def publish( def add_job(self, target: Callable[..., Any], *args: Any) -> None: asyncio.new_event_loop().run_until_complete(target(*args)) + + +STREAM_SENSOR = hksv3.SensorConfiguration( + UUID("00112233-4455-6677-8899-aabbccddeeff"), + 3840, + 2160, + video_streams=[ + hksv3.VideoStreamCapability(UUID(int=1), 2, 1920, 1080, 30, 1700, 1800), + hksv3.VideoStreamCapability(UUID(int=2), 3, 1280, 720, 30, 768, 800), + hksv3.VideoStreamCapability(UUID(int=3), 4, 640, 360, 15, 180, 190), + ], +) + + +@pytest.fixture +def stream_sensor() -> hksv3.SensorConfiguration: + """A 4K sensor with High, Medium and Low encodings.""" + return STREAM_SENSOR + + +class _StubStreamController(StreamController[StreamSession]): + """A controller whose delegate only releases sessions.""" + + def __init__(self, sensor: hksv3.SensorConfiguration, release: AsyncMock) -> None: + camera = MagicMock() + camera.driver.loop = asyncio.get_running_loop() + super().__init__(camera, sensor, 0.05, 5, 3) + self.release = release + + async def _release_hook(self, session: StreamSession) -> None: + await self.release(session) + + +@pytest.fixture +def session_release() -> AsyncMock: + """The release hook of ``session_controller``.""" + return AsyncMock() + + +@pytest.fixture +async def session_controller( + stream_sensor: hksv3.SensorConfiguration, session_release: AsyncMock +) -> StreamController[StreamSession]: + """A stream controller with a hook timeout of 50 ms and 5 sessions, 3 each.""" + return _StubStreamController(stream_sensor, session_release) + + +@pytest.fixture +def add_session( + session_controller: StreamController[StreamSession], +) -> Callable[[Any], StreamSession]: + """Add a new session of a connection to ``session_controller``.""" + + def _add(client_addr: Any) -> StreamSession: + session = StreamSession( + id=uuid4(), sensor=session_controller.sensor, client_addr=client_addr + ) + session_controller._add(session) + return session + + return _add diff --git a/tests/test_camera_stream.py b/tests/test_camera_stream.py new file mode 100644 index 00000000..7bc95e25 --- /dev/null +++ b/tests/test_camera_stream.py @@ -0,0 +1,481 @@ +"""Tests for the HKSV 17.99 stream session state machine of pyhap.camera_stream.""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pyhap import hksv3 +from pyhap.camera_stream import ( + MAX_HOOK_TIMEOUT, + HookFailed, + SessionState, + StreamController, + StreamError, + StreamSession, + check_hook_timeout, + config_from_option, + failure_status, +) + +CLIENT = ("192.168.1.114", 50000) +OTHER = ("192.168.1.115", 50000) + + +@dataclass(frozen=True) +class _Config: + max_sessions: int = 5 + + +async def _hook(result: Any = 1) -> Any: + await asyncio.sleep(0) + return result + + +async def _never() -> None: + await asyncio.Event().wait() + + +async def _raise(error: BaseException) -> None: + raise error + + +async def _stubborn(started: asyncio.Event, finished: asyncio.Event) -> None: + """A hook that takes 0.2 s to give up after it was cancelled.""" + started.set() + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0.2) + finished.set() + + +async def test_hook_result( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a hook's result comes back and the session stays.""" + session = add_session(CLIENT) + deadline = session_controller._deadline() + + assert await session_controller._run_hook(session, "test", _hook(5), deadline) == 5 + + assert session.busy is False + assert session_controller.sessions == {session.id: session} + session_release.assert_not_called() + + +async def test_hook_timeout_ends_the_session( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a slow hook is cancelled, then the session is released once.""" + session = add_session(CLIENT) + deadline = session_controller._deadline() + + with pytest.raises(HookFailed) as failed: + await session_controller._run_hook( + session, "test", _never(), deadline, first=True + ) + assert failed.value.status is None + assert session.state is SessionState.ENDED + assert session_controller.sessions == {} + await session_controller.close() + + session_release.assert_awaited_once_with(session) + assert session.busy is False + + +@pytest.mark.parametrize( + ("error", "status"), + [ + (StreamError(hksv3.RTPStreamingStatus.BUSY), 3), + (NotImplementedError(), None), + (RuntimeError("boom"), None), + ], +) +async def test_hook_errors( + session_controller: StreamController[StreamSession], + error: Exception, + status: int | None, + add_session: Callable[..., StreamSession], +) -> None: + """Test a delegate's status is kept, other errors have none.""" + session = add_session(CLIENT) + + with pytest.raises(HookFailed) as failed: + await session_controller._run_hook( + session, "test", _raise(error), session_controller._deadline() + ) + + assert failed.value.status == status + assert session.state is SessionState.NEW + + +async def test_failed_hook_may_end_the_session( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a failed first hook ends the session and still releases it.""" + session = add_session(CLIENT) + + with pytest.raises(HookFailed): + await session_controller._run_hook( + session, + "test", + _raise(RuntimeError()), + session_controller._deadline(), + end_on_failure=True, + first=True, + ) + await session_controller.close() + + assert session.state is SessionState.ENDED + session_release.assert_awaited_once_with(session) + + +async def test_end_releases_once( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test ending twice releases a session the delegate saw exactly once.""" + session = add_session(CLIENT) + await session_controller._run_hook( + session, "test", _hook(), session_controller._deadline(), first=True + ) + + first = session_controller._end(session, "first") + second = session_controller._end(session, "second") + + assert first is second + assert first is not None + assert await first is True + session_release.assert_awaited_once_with(session) + + +async def test_end_without_delegate( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a session the delegate never saw is not released.""" + session = add_session(CLIENT) + + assert session_controller._end(session, "test") is None + assert await session_controller.end_session(session.id) is False + session_release.assert_not_called() + + +async def test_end_waits_for_cancelled_hooks( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test the release starts once a running hook was cancelled.""" + session = add_session(CLIENT) + session._delegated = True + hook = asyncio.ensure_future( + session_controller._run_hook( + session, "test", _never(), session_controller._deadline() + ) + ) + await asyncio.sleep(0) + + assert await session_controller.end_session(session.id) is True + + session_release.assert_awaited_once_with(session) + with pytest.raises(HookFailed): + await hook + + +async def test_release_failure_is_logged( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + caplog: pytest.LogCaptureFixture, + add_session: Callable[..., StreamSession], +) -> None: + """Test a failing release still ends the session.""" + session_release.side_effect = RuntimeError + session = add_session(CLIENT) + session._delegated = True + + release_task = session_controller._end(session, "test") + + assert release_task is not None + assert await release_task is False + assert "Releasing the session failed" in caplog.text + + +async def test_cancelled_request_ends_the_session( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test the session ends when the request waiting for its hook goes away.""" + session = add_session(CLIENT) + request = asyncio.ensure_future( + session_controller._run_hook( + session, "test", _never(), session_controller._deadline() + ) + ) + await asyncio.sleep(0) + + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + + assert session.state is SessionState.ENDED + + +async def test_hook_of_ended_session( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test no hook runs for a session that already ended.""" + session = add_session(CLIENT) + session_controller._end(session, "test") + hook = _hook() + + with pytest.raises(HookFailed): + await session_controller._run_hook( + session, "test", hook, session_controller._deadline() + ) + + assert hook.cr_frame is None + + +async def test_timer_rechecks_the_state( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test a timer only ends a session still in one of its states.""" + waiting = add_session(CLIENT) + moved_on = add_session(CLIENT) + session_controller._set_timer(waiting, 0, "too slow", SessionState.NEW) + session_controller._set_timer(moved_on, 0, "too slow", SessionState.NEW) + moved_on.state = SessionState.ACTIVE + + await asyncio.sleep(0.01) + + assert waiting.state is SessionState.ENDED + assert moved_on.state is SessionState.ACTIVE + + +async def test_end_cancels_the_timer( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test an ended session's timer does not fire.""" + session = add_session(CLIENT) + session_controller._set_timer(session, 10, "too slow", SessionState.NEW) + + session_controller._end(session, "test") + + assert session._timer is None + + +async def test_ownership( + session_controller: StreamController[StreamSession], + caplog: pytest.LogCaptureFixture, + add_session: Callable[..., StreamSession], +) -> None: + """Test only the connection that set a session up finds it.""" + session = add_session(CLIENT) + + assert session_controller._owned(session.id, CLIENT) is session + assert session_controller._owned(session.id, OTHER) is None + assert session_controller._owned(uuid4(), CLIENT) is None + assert "Not the controller of the session" in caplog.text + + +async def test_add_refuses_a_taken_id( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test a session with the ID of another one is not registered.""" + session = add_session(CLIENT) + twin = StreamSession(id=session.id, sensor=session.sensor, client_addr=CLIENT) + + with pytest.raises(RuntimeError, match="Another session has the ID"): + session_controller._add(twin) + assert session_controller.sessions[session.id] is session + + +async def test_client_disconnected( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a closed connection ends only its own sessions.""" + mine = add_session(CLIENT) + other = add_session(OTHER) + mine._delegated = True + + session_controller.client_disconnected(CLIENT) + + assert mine.state is SessionState.ENDED + assert session_controller.sessions == {other.id: other} + await session_controller.close() + session_release.assert_awaited_once_with(mine) + + +async def test_end_all_sessions( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test ending all sessions waits for each release.""" + first = add_session(CLIENT) + second = add_session(OTHER) + first._delegated = second._delegated = True + + await session_controller.end_all_sessions() + + assert session_controller.sessions == {} + assert session_release.await_count == 2 + + +async def test_end_by_request_waits_until_the_deadline( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a request gives up on a slow release at its deadline.""" + session_release.side_effect = _never + session = add_session(CLIENT) + session._delegated = True + + assert ( + await session_controller._end_by_request( + session, session_controller._deadline() + ) + is False + ) + assert session.state is SessionState.ENDED + + +@pytest.mark.parametrize("hook_timeout", [0, -1, MAX_HOOK_TIMEOUT + 0.1]) +def test_hook_timeout_leaves_time_to_answer(hook_timeout: float) -> None: + """Test hooks must end before the HAP response timeout.""" + with pytest.raises(ValueError, match="hook_timeout"): + check_hook_timeout(hook_timeout) + + +def test_config_from_option() -> None: + """Test options build a config, a config is taken as it is.""" + config = _Config(max_sessions=6) + + assert config_from_option(_Config, config, "multi_tier") is config + assert config_from_option(_Config, {"max_sessions": 6}, "x") == config + with pytest.raises(ValueError, match="Unknown multi_tier option: start"): + config_from_option(_Config, {"start": 1}, "multi_tier") + with pytest.raises(ValueError, match="must be a dict"): + config_from_option(_Config, [], "multi_tier") + + +@pytest.mark.parametrize( + ("status", "expected"), + [(None, 4), (0, 4), (3, 3), (99, 4)], +) +def test_failure_status(status: int | None, expected: int) -> None: + """Test a delegate picks a known failure status, else the error.""" + error = hksv3.RTPStreamingStatus.ERROR + assert failure_status(hksv3.RTPStreamingStatus, status, error) == expected + + +async def test_session_end( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], +) -> None: + """Test a session ends itself on the loop and is released once.""" + session = add_session(CLIENT) + session._delegated = True + + session.end("the media stopped") + assert session.state is SessionState.NEW + await asyncio.sleep(0) + session.end("again") + await session_controller.close() + + assert session.state is SessionState.ENDED + assert session_controller.sessions == {} + session_release.assert_awaited_once_with(session) + + +def test_session_end_without_controller( + stream_sensor: hksv3.SensorConfiguration, +) -> None: + """Test a session no controller registered cannot end itself.""" + session = StreamSession(id=uuid4(), sensor=stream_sensor, client_addr=CLIENT) + + session.end() + + assert session.state is SessionState.NEW + + +async def test_release_after_a_stuck_hook( + session_controller: StreamController[StreamSession], + session_release: AsyncMock, + add_session: Callable[..., StreamSession], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the release waits for a cancelled hook only up to hook_timeout.""" + session = add_session(CLIENT) + session._delegated = True + started = asyncio.Event() + finished = asyncio.Event() + hook = _stubborn(started, finished) + request = asyncio.ensure_future( + session_controller._run_hook( + session, "test", hook, session_controller._deadline() + ) + ) + await started.wait() + + release = session_controller._end(session, "test") + + assert release is not None + assert await release is True + assert not finished.is_set() + assert "while 1 cancelled hooks still run" in caplog.text + session_release.assert_awaited_once_with(session) + with pytest.raises(HookFailed): + await request + await finished.wait() + + +async def test_no_room( + session_controller: StreamController[StreamSession], + add_session: Callable[..., StreamSession], +) -> None: + """Test the limits of sessions in all and per connection.""" + add_session(CLIENT) + add_session(CLIENT) + assert session_controller._no_room(CLIENT) is None + + add_session(CLIENT) + assert session_controller._no_room(CLIENT) == ( + "Too many stream sessions of the connection" + ) + assert session_controller._no_room(OTHER) is None + + add_session(OTHER) + add_session(OTHER) + assert session_controller._no_room(OTHER) == "Too many stream sessions" + + +async def test_no_room_once_closed( + session_controller: StreamController[StreamSession], +) -> None: + """Test a closed controller takes no new session.""" + await session_controller.close() + + assert session_controller._no_room(CLIENT) == "The stream service is closed" From 4e2f7050a3e2912eb1e108e0d4c7c6a9f538e80e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:42:10 +0100 Subject: [PATCH 28/56] Add the HKSV 17.99 multi-tier RTP and WebRTC stream services A controller that streams through the Camera Multi-Tier RTP Stream Management service of the HomeKit Secure Video 17.99 guide does not fall back to the RTP stream management service for remote viewing, which needs the Camera WebRTC Stream Management service. So both come together or not at all. The new multi_tier and webrtc options add a MultiTierController and a WebRTCController per sensor, in the spirit of the SecureVideoController of HAP-NodeJS #1132. Camera composes them. The options take a MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown keys raise. Both need the sensors, global_operating_mode, recording, video and audio options. iOS 27 shows a camera without the classic RTP stream management service as not responding. The services link to each other and to the data stream transport. The multi-tier service offers the sensor's video encodings as H.265 tiers and exactly one Opus tier, with the payload types 99 and 110. The WebRTC service offers the same tiers and picks the one a remote offer advertises, by default the Medium one. The tiers are checked against the guide. Status Active follows the global operating mode and the service's Streaming Enabled, which survives a restart, and the sessions of a service end when it may no longer stream. Each service has at least the sessions the guide requires, five and six. max_sessions_per_connection limits one connection: three by default for multi-tier, and all of them for WebRTC, since remote viewers share the home hub's connection. The media comes from typed delegates, MultiTierDelegate and WebRTCDelegate, which release a session in handle_end(). The sessions follow in the next changes. Camera ends the sessions of a closed connection, stops them with the camera and has end_session() and end_all_sessions() for the integrator. --- pyhap/camera.py | 179 +++++++++++++++++++++++- pyhap/camera_multi_tier.py | 241 ++++++++++++++++++++++++++++++++ pyhap/camera_webrtc.py | 230 ++++++++++++++++++++++++++++++ tests/conftest.py | 74 +++++++++- tests/test_camera_multi_tier.py | 214 ++++++++++++++++++++++++++++ tests/test_camera_webrtc.py | 22 +++ 6 files changed, 954 insertions(+), 6 deletions(-) create mode 100644 pyhap/camera_multi_tier.py create mode 100644 pyhap/camera_webrtc.py create mode 100644 tests/test_camera_multi_tier.py create mode 100644 tests/test_camera_webrtc.py diff --git a/pyhap/camera.py b/pyhap/camera.py index 8f27c2d7..af83ae63 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -17,7 +17,7 @@ """ import asyncio -from collections.abc import AsyncGenerator, Callable, Iterable +from collections.abc import AsyncGenerator, Callable, Iterable, Mapping import functools import hashlib import ipaddress @@ -25,7 +25,8 @@ import os import struct import sys -from typing import Any +from types import MappingProxyType +from typing import TYPE_CHECKING, Any from uuid import UUID from pyhap import ( @@ -44,6 +45,10 @@ from pyhap.service import Service from pyhap.util import base64_to_bytes, byte_bool, callback, to_base64_str +if TYPE_CHECKING: + from pyhap.camera_multi_tier import MultiTierController + from pyhap.camera_webrtc import WebRTCController + if sys.version_info >= (3, 11): from asyncio import timeout as async_timeout else: @@ -622,6 +627,23 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: at least three video encodings. With ``recording``, turning it on for a paired camera changes the instance IDs of the recording services with the default IID manager. + - multi_tier - :class:`pyhap.camera_multi_tier.MultiTierConfig`, or a + dict of its fields. Developer Preview of HomeKit Secure Video + 17.99, experimental. Adds a Camera Multi-Tier RTP Stream + Management service per sensor, offering the sensor's video + encodings as H.265 tiers. Needs ``webrtc``, ``sensors``, + ``global_operating_mode``, ``recording``, ``video`` and + ``audio``: iOS 27 shows a camera without the classic RTP stream + management service as not responding. Its services come before + the RTP stream management services, which then get a new + ``unique_id``; for a paired camera this changes instance IDs. + - webrtc - :class:`pyhap.camera_webrtc.WebRTCConfig`, or a dict of + its fields. Developer Preview of HomeKit Secure Video 17.99, + experimental. Adds a Camera WebRTC Stream Management service per + sensor, linked to its multi-tier service. Controllers view + remotely through it: one that streams through the multi-tier + service does not fall back to the RTP stream management service. + Needs ``multi_tier`` and the options that it needs. :type options: ``dict`` """ @@ -681,11 +703,20 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: self._motion_zones_service: Service | None = None self._contributing_sensors_char: Characteristic | None = None self._motion_enabled_char: Characteristic | None = None + self._multi_tier: dict[UUID, MultiTierController] = {} + self._webrtc: dict[UUID, WebRTCController] = {} + self._stream_status_scheduled = False + if options.get("multi_tier") is not None or options.get("webrtc") is not None: + self._setup_stream_controllers(options) if options.get("video"): self._setup_stream_management(options) # Classic HomeKit Secure Video recording (HDS + fragmented MP4). if options.get("recording"): self._setup_recording_management(options) + transport = self.get_service("DataStreamTransportManagement") + if transport is not None: + for controller in self._stream_controllers(): + controller.service.add_linked_service(transport) if self._sensors: self._setup_camera_capabilities() # Last, so the instance IDs of the other services stay the same. @@ -710,8 +741,11 @@ def _create_stream_management( self, stream_idx: int, options: dict[str, Any] ) -> Service: """Create a stream management service.""" + # A new unique_id lets a persistent IID manager place the service + # after the multi-tier ones, which come first. + unique_id = stream_idx if not self._multi_tier else f"rtp-{stream_idx}" management = self.add_preload_service( - "CameraRTPStreamManagement", unique_id=stream_idx, chars=["Active"] + "CameraRTPStreamManagement", unique_id=unique_id, chars=["Active"] ) # HAP-NodeJS exposes an Active (0x0B0) characteristic on every stream # management service (value 1 = active); iOS' camera validation expects @@ -747,6 +781,127 @@ def _create_stream_management( setup_endpoints.read_back_response = True return management + def _setup_stream_controllers(self, options: dict[str, Any]) -> None: + """Add the multi-tier and WebRTC controllers of each sensor (17.99).""" + # Here, not at the top: these modules import this one. + from pyhap.camera_multi_tier import ( # noqa: PLC0415 + MultiTierConfig, + MultiTierController, + ) + from pyhap.camera_webrtc import WebRTCConfig, WebRTCController # noqa: PLC0415 + + if options.get("multi_tier") is None or options.get("webrtc") is None: + raise ValueError( + "multi_tier and webrtc need each other, without WebRTC " + "controllers cannot view remotely" + ) + if ( + not options.get("video") + or not options.get("audio") + or options.get("stream_count", 1) < 1 + ): + raise ValueError( + "multi_tier and webrtc need the video and audio options, iOS 27 " + "shows a camera without the classic RTP stream management " + "service as not responding" + ) + if ( + not self._sensors + or options.get("global_operating_mode") is None + or not options.get("recording") + ): + raise ValueError( + "multi_tier and webrtc need the sensors, global_operating_mode " + "and recording options" + ) + multi_tier = MultiTierConfig.from_option(options["multi_tier"]) + webrtc = WebRTCConfig.from_option(options["webrtc"]) + for sensor in self._sensors: + for warning in hksv3.check_video_tiers(sensor): + logger.warning("Sensor %s: %s", sensor.sensor_uuid, warning) + controller = MultiTierController(self, sensor, multi_tier) + self._multi_tier[sensor.sensor_uuid] = controller + self._webrtc[sensor.sensor_uuid] = WebRTCController( + self, controller, webrtc + ) + for kind, controllers in ( + ("multi_tier", self._multi_tier), + ("webrtc", self._webrtc), + ): + for sensor_uuid, controller in controllers.items(): + self._persist_hksv3_char( + controller.streaming_enabled, + f"{kind}.{sensor_uuid}.streaming_enabled", + self._handle_stream_enabled, + ) + + def _stream_controllers( + self, + ) -> "list[MultiTierController | WebRTCController]": + return [*self._multi_tier.values(), *self._webrtc.values()] + + @property + def multi_tier_controllers(self) -> "Mapping[UUID, MultiTierController]": + """The multi-tier RTP controller of each sensor (17.99), by sensor UUID.""" + return MappingProxyType(self._multi_tier) + + @property + def webrtc_controllers(self) -> "Mapping[UUID, WebRTCController]": + """The WebRTC controller of each sensor (17.99), by sensor UUID.""" + return MappingProxyType(self._webrtc) + + def _handle_stream_enabled(self, value: Any) -> None: + self._schedule_stream_status() + + def _schedule_stream_status(self) -> None: + """Update the stream controllers once the current write is done. + + A controller writes several of the values they depend on in one + request, e.g. HomeKit Camera Active and Streaming Enabled. + """ + if self._stream_status_scheduled or not self._multi_tier: + return + self._stream_status_scheduled = True + self.driver.loop.call_soon(self._update_stream_status) + + def _update_stream_status(self) -> None: + """Let each stream controller derive whether it may stream now. + + Ends the sessions that may no longer stream. + """ + self._stream_status_scheduled = False + for controller in self._stream_controllers(): + controller.update_status() + + async def end_session(self, session_id: UUID) -> bool: + """End a multi-tier or WebRTC session (17.99) on the camera's side. + + E.g. when its media source stopped. Ends the sessions with that ID of + every sensor and service and waits until their delegates released + them. Returns False if there is no such session. + """ + ended = await asyncio.gather( + *( + controller.end_session(session_id) + for controller in self._stream_controllers() + ) + ) + return any(ended) + + async def end_all_sessions(self) -> None: + """End every multi-tier and WebRTC session (17.99) and wait for them.""" + await asyncio.gather( + *( + controller.end_all_sessions() + for controller in self._stream_controllers() + ) + ) + + def client_disconnected(self, client_addr: tuple[str, int]) -> None: + """End the multi-tier and WebRTC sessions of the connection.""" + for controller in self._stream_controllers(): + controller.client_disconnected(client_addr) + def _default_recording_configs( self, options: dict[str, Any] ) -> tuple[ @@ -1147,6 +1302,7 @@ def set_manually_disabled(self, disabled: bool) -> None: char = self._global_operating_mode_char("ManuallyDisabled") if char is not None: char.set_value(bool(disabled)) + self._update_stream_status() def set_third_party_camera_active(self, active: bool) -> None: """Report whether the camera is on for apps outside HomeKit. @@ -1166,6 +1322,7 @@ def _global_operating_mode_char(self, name: str) -> Characteristic | None: return None def _handle_global_operating_mode(self, name: str, value: Any) -> None: + self._schedule_stream_status() self.global_operating_mode_changed(name, value) def global_operating_mode_changed(self, name: str, value: Any) -> None: @@ -1294,6 +1451,8 @@ def _restore_hksv3_state(self) -> None: if self.aid is not None: self._load_hksv3_state(self.aid) self._hksv3_seen = {char: char.value for char in self._hksv3_defaults} + # Restored values bypass the handlers. + self._update_stream_status() def _load_hksv3_state(self, aid: int) -> None: stored = self.driver.accessory_state(aid).get(HKSV3_STATE) @@ -1376,6 +1535,7 @@ def reset_accessory_state(self) -> None: self._schedule_recording_save() for handler, value in handlers: handler(value) + self._update_stream_status() def set_motion_detected( self, detected: bool, sensors: Iterable[UUID] | None = None @@ -1587,6 +1747,7 @@ def _handle_homekit_camera_active(self, value: Any) -> None: self._motion_status_active_char.notify() if not active and self._recording_stream is not None: self._recording_stream.close(hds_recording.RecordingReason.NOT_ALLOWED) + self._schedule_stream_status() self.homekit_camera_active_changed(active) def homekit_camera_active_changed(self, active: bool) -> None: @@ -1867,9 +2028,17 @@ async def stop_recording_transport(self) -> None: await listener.stop() async def stop(self) -> None: - """Stop all streaming sessions and the HDS listener.""" + """Stop all streaming sessions and the HDS listener. + + The HKSV 17.99 stream services stay closed. Create a new accessory to + stream on them again. + """ await asyncio.gather( - *(self.stop_stream(session_info) for session_info in self.sessions.values()) + *( + self.stop_stream(session_info) + for session_info in self.sessions.values() + ), + *(controller.close() for controller in self._stream_controllers()), ) await self.stop_recording_transport() diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py new file mode 100644 index 00000000..ea2fcc3d --- /dev/null +++ b/pyhap/camera_multi_tier.py @@ -0,0 +1,241 @@ +"""Multi-tier RTP streaming of a HomeKit Secure Video 17.99 camera. + +Developer Preview: HomeKit Secure Video 17.99 is a preview of the guide, so +this API is experimental and may change. + +:class:`pyhap.camera.Camera` adds a :class:`MultiTierController` per sensor +for its ``multi_tier`` option. Controllers on the local network stream +through it: they set up a session with Setup Endpoints and start, change or +end its stream with RTP Streaming Control. A :class:`MultiTierDelegate` sends +the media, it gets the sensor and the tiers the controller picked and maps +them to its sources. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +import logging +from typing import TYPE_CHECKING, Any, Protocol, cast + +from pyhap import hksv3 +from pyhap.camera_stream import ( + DEFAULT_HOOK_TIMEOUT, + StreamController, + StreamSession, + check_hook_timeout, + config_from_option, +) +from pyhap.util import to_base64_str + +if TYPE_CHECKING: + from pyhap.camera import Camera + +__all__ = [ + "MULTI_TIER_MIN_SESSIONS", + "MultiTierConfig", + "MultiTierController", + "MultiTierDelegate", + "MultiTierSession", +] + +logger = logging.getLogger(__name__) + +# Multi-tier sessions per sensor the guide requires at least. +MULTI_TIER_MIN_SESSIONS = 5 + + +@dataclass(eq=False, kw_only=True) +class MultiTierSession(StreamSession): + """A multi-tier RTP session a controller set up with Setup Endpoints. + + ``address`` and the ports are where the controller receives, with the + SRTP keys it sent. The accessory sends from ``accessory_address`` and the + ``accessory_*_port`` values, by default its address on the controller's + connection and the controller's port numbers; ``handle_prepare`` may + change them. ``video_ssrc`` and ``audio_ssrc`` are the accessory's SSRCs + from the Setup Endpoints answer, the controller filters by them. The + ``controller_*_ssrc`` values are what the controller sent with the last + start. ``video_tier`` and ``audio_tier`` are what streams once started. + """ + + address: str + is_ipv6: bool + video_port: int + audio_port: int + video_srtp_key: bytes = field(repr=False) + video_srtp_salt: bytes = field(repr=False) + audio_srtp_key: bytes = field(repr=False) + audio_srtp_salt: bytes = field(repr=False) + video_ssrc: int + audio_ssrc: int + accessory_address: str + accessory_video_port: int + accessory_audio_port: int + controller_video_ssrc: int | None = None + controller_audio_ssrc: int | None = None + video_tier: hksv3.VideoStreamTier | None = None + audio_tier: hksv3.AudioStreamTier | None = None + + +class MultiTierDelegate(Protocol): + """Sends the media of multi-tier RTP sessions. + + Each hook may raise :class:`pyhap.camera_stream.StreamError` to answer + with a status, any other exception answers with an error. A hook that + takes longer than ``hook_timeout`` is cancelled and its session ends. + """ + + async def handle_prepare(self, session: MultiTierSession) -> None: + """Prepare a session before the Setup Endpoints answer. + + May set the ``accessory_*`` values of ``session`` and keep anything in + ``session.data``. Raise ``StreamError`` with a + :class:`pyhap.hksv3.SetupEndpointsStatus` to refuse it. + """ + + async def handle_start( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Start streaming ``session.sensor`` in the given tiers. + + Send video with ``session.video_ssrc`` and audio with + ``session.audio_ssrc``, SRTP protected with the session keys and with + the payload types of :mod:`pyhap.hksv3`. ``audio_tier`` is None if the + controller wants no audio: then send none. + """ + + async def handle_reconfigure( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Switch a started session to other tiers. + + Keep the SSRCs, the SRTP keys and the SRTP packet index of the running + stream: a new sender with the same SSRC and key repeats the keystream. + A delegate that cannot change the tier of a running stream, e.g. one + that copies its source, keeps the stream as it is. + """ + + async def handle_end(self, session: MultiTierSession) -> None: + """Release a session that ended, whatever state it reached. + + Called exactly once for every session ``handle_prepare`` saw, also + after a failed prepare, start or reconfigure. + """ + + +@dataclass(frozen=True, kw_only=True) +class MultiTierConfig: + """The ``multi_tier`` option of :class:`pyhap.camera.Camera`. + + ``delegate`` sends the media. ``audio`` is the one audio tier the guide + allows. ``max_sessions`` per sensor is at least five, as the guide + requires, and one connection has at most ``max_sessions_per_connection`` + of them. A delegate hook gets ``hook_timeout`` seconds. + """ + + delegate: MultiTierDelegate | None = None + audio: hksv3.AudioStreamTier = hksv3.DEFAULT_AUDIO_TIER + max_sessions: int = MULTI_TIER_MIN_SESSIONS + max_sessions_per_connection: int = 3 + hook_timeout: float = DEFAULT_HOOK_TIMEOUT + + @classmethod + def from_option(cls, value: MultiTierConfig | Mapping[str, Any]) -> MultiTierConfig: + """Take the option as it is or build it from a dict of its fields. + + :raises ValueError: For an unknown key. + """ + return config_from_option(cls, value, "multi_tier") + + def __post_init__(self) -> None: + check_hook_timeout(self.hook_timeout) + if self.max_sessions < MULTI_TIER_MIN_SESSIONS: + raise ValueError( + f"multi_tier max_sessions must be at least {MULTI_TIER_MIN_SESSIONS}" + ) + if not 0 < self.max_sessions_per_connection <= self.max_sessions: + raise ValueError( + "multi_tier max_sessions_per_connection must be 1 to max_sessions" + ) + if not isinstance(self.audio, hksv3.AudioStreamTier): + raise ValueError("multi_tier audio must be exactly one audio tier") + hksv3.check_audio_tier(self.audio) + + +def supported_audio_tiers(audio: hksv3.AudioStreamTier) -> bytes: + """The Supported Audio Stream Tiers value, exactly one Opus tier.""" + return hksv3.SupportedAudioStreamTiers( + hksv3.AudioCodec.OPUS, hksv3.AUDIO_PAYLOAD_TYPE, [audio] + ).encode() + + +def supported_video_tiers(tiers: list[hksv3.VideoStreamTier]) -> bytes: + """The Supported Video Stream Tiers value, H.265 only.""" + return hksv3.SupportedVideoStreamTiers( + hksv3.VideoCodec.H265, hksv3.VIDEO_PAYLOAD_TYPE, tiers + ).encode() + + +class MultiTierController(StreamController[MultiTierSession]): + """The Camera Multi-Tier RTP Stream Management service of one sensor.""" + + kind = "multi-tier" + + def __init__( + self, + camera: Camera, + sensor: hksv3.SensorConfiguration, + config: MultiTierConfig, + ) -> None: + super().__init__( + camera, + sensor, + config.hook_timeout, + config.max_sessions, + config.max_sessions_per_connection, + ) + self.config = config + self.delegate = config.delegate + self.tiers = hksv3.tiers_from_sensor(sensor) + service = self.service = camera.add_preload_service( + "CameraMultiTierRTPStreamManagement", unique_id=str(sensor.sensor_uuid) + ) + for name, value in ( + ("SensorUUID", sensor.sensor_uuid.bytes), + ("SupportedVideoStreamTiers", supported_video_tiers(self.tiers)), + ("SupportedAudioStreamTiers", supported_audio_tiers(config.audio)), + ): + service.configure_char(name, value=to_base64_str(value)) + # The guide allows AES_CM_128_HMAC_SHA1_80 only. + service.configure_char( + "SupportedRTPConfiguration", value=camera.get_supported_rtp_config(True) + ) + self.streaming_enabled = service.configure_char("StreamingEnabled", value=True) + self._status_active = service.get_characteristic("StatusActive") + + @property + def active(self) -> bool: + """Status Active: whether the sensor may stream now.""" + return bool(self._status_active.value) + + def update_status(self) -> None: + """Derive Status Active, ending the sessions if it turned off.""" + active = self.camera.streaming_allowed and bool(self.streaming_enabled.value) + self._status_active.set_value(active) + if not active: + self._end_sessions("streaming was turned off") + + @property + def _delegate(self) -> MultiTierDelegate: + # Only a controller with a delegate has sessions. + return cast("MultiTierDelegate", self.delegate) + + async def _release_hook(self, session: MultiTierSession) -> None: + await self._delegate.handle_end(session) diff --git a/pyhap/camera_webrtc.py b/pyhap/camera_webrtc.py new file mode 100644 index 00000000..5ae66969 --- /dev/null +++ b/pyhap/camera_webrtc.py @@ -0,0 +1,230 @@ +"""WebRTC streaming of a HomeKit Secure Video 17.99 camera. + +Developer Preview: HomeKit Secure Video 17.99 is a preview of the guide, so +this API is experimental and may change. + +:class:`pyhap.camera.Camera` adds a :class:`WebRTCController` per sensor for +its ``webrtc`` option, next to the multi-tier RTP service. Controllers view +remotely through it. The accessory offers, the controller answers, may +renegotiate and update its SFrame keys, and ends the session. A +:class:`WebRTCDelegate` runs the WebRTC media; it shares +:class:`pyhap.hksv3.WebRTCOffer`, :class:`pyhap.hksv3.WebRTCReofferAnswer`, +:class:`pyhap.hksv3.IceCandidate` and :class:`pyhap.hksv3.SFrameKey` with +the HAP side. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Any, Protocol, cast + +from pyhap import hksv3 +from pyhap.camera_stream import ( + DEFAULT_HOOK_TIMEOUT, + StreamController, + StreamSession, + check_hook_timeout, + config_from_option, +) + +if TYPE_CHECKING: + from pyhap.camera import Camera + from pyhap.camera_multi_tier import MultiTierController + +__all__ = [ + "WEBRTC_MIN_SESSIONS", + "WebRTCConfig", + "WebRTCController", + "WebRTCDelegate", + "WebRTCSession", +] + +logger = logging.getLogger(__name__) + +# WebRTC sessions per sensor the guide requires at least. +WEBRTC_MIN_SESSIONS = 6 + + +@dataclass(eq=False, kw_only=True) +class WebRTCSession(StreamSession): + """A WebRTC session, from the Solicit Offer write until it ends. + + ``video_tier`` and ``audio_tier`` are what the offer advertises. + ``sframe`` tells whether the media is SFrame encrypted: unless the + controller explicitly turned it off, as the Scrypted HomeKit plugin found + remote viewing needs it even when the controller leaves the option out. + """ + + sframe: bool + video_tier: hksv3.VideoStreamTier + audio_tier: hksv3.AudioStreamTier + + +class WebRTCDelegate(Protocol): + """Runs the WebRTC media of the sessions. + + Each hook may raise :class:`pyhap.camera_stream.StreamError` to answer + with a status, any other exception answers with an error. A hook that + takes longer than ``hook_timeout`` is cancelled and its session ends. + """ + + async def handle_solicit_offer( + self, + session: WebRTCSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier, + ) -> hksv3.WebRTCOffer: + """Create the offer of a new session. + + Offer ``session.sensor`` in the given tiers, with the payload types of + :mod:`pyhap.hksv3`: H.265 video limited by ``a=rid`` to the tier's + size, frame rate and peak bit rate, and Opus audio. The SDP carries + all ICE candidates. Return an SFrame key exactly if ``session.sframe``. + Bind what the offer needs, but start the media only once answered. + """ + + async def handle_answer( + self, + session: WebRTCSession, + sdp: str, + candidates: list[hksv3.IceCandidate], + ) -> None: + """Apply the controller's answer and its ICE candidates.""" + + async def handle_reoffer( + self, + session: WebRTCSession, + sdp: str, + options: hksv3.WebRTCOfferOptions, + ) -> hksv3.WebRTCReofferAnswer: + """Answer the controller's new offer for an active session.""" + + async def handle_update_session( + self, + session: WebRTCSession, + add_keys: list[hksv3.SFrameKey], + remove_kids: list[int], + ) -> None: + """Change the SFrame keys the controller sends with.""" + + async def handle_end(self, session: WebRTCSession) -> None: + """Release a session that ended, whatever state it reached. + + Called exactly once for every session ``handle_solicit_offer`` saw, + also after a failed offer or answer and for an offer never answered. + """ + + +@dataclass(frozen=True, kw_only=True) +class WebRTCConfig: + """The ``webrtc`` option of :class:`pyhap.camera.Camera`. + + ``delegate`` runs the media, without one every offer fails. + ``max_sessions`` per sensor is at least six, as the guide requires. + ``max_sessions_per_connection`` limits one connection, by default to + ``max_sessions``, since remote viewers share the home hub's connection. A + delegate hook gets ``hook_timeout`` seconds. The offer + advertises the sensor's video tier of ``remote_quality``, the lowest tier + if it has none. Medium keeps 4K off the remote path; the Scrypted HomeKit + plugin offers the first tier. + """ + + delegate: WebRTCDelegate | None = None + max_sessions: int = WEBRTC_MIN_SESSIONS + max_sessions_per_connection: int | None = None + hook_timeout: float = DEFAULT_HOOK_TIMEOUT + remote_quality: hksv3.VideoQuality | int = hksv3.VideoQuality.MEDIUM + + @classmethod + def from_option(cls, value: WebRTCConfig | Mapping[str, Any]) -> WebRTCConfig: + """Take the option as it is or build it from a dict of its fields. + + :raises ValueError: For an unknown key. + """ + return config_from_option(cls, value, "webrtc") + + def __post_init__(self) -> None: + check_hook_timeout(self.hook_timeout) + if self.max_sessions < WEBRTC_MIN_SESSIONS: + raise ValueError( + f"webrtc max_sessions must be at least {WEBRTC_MIN_SESSIONS}" + ) + per_connection = self.max_sessions_per_connection + if per_connection is not None and not 0 < per_connection <= self.max_sessions: + raise ValueError( + "webrtc max_sessions_per_connection must be 1 to max_sessions" + ) + if self.remote_quality not in set(hksv3.VideoQuality): + raise ValueError(f"Unknown webrtc remote_quality {self.remote_quality}") + + +def remote_tier( + tiers: list[hksv3.VideoStreamTier], quality: hksv3.VideoQuality | int +) -> hksv3.VideoStreamTier: + """The smallest tier of ``quality``, else the smallest tier.""" + matching = [tier for tier in tiers if tier.quality == quality] or tiers + return min(matching, key=lambda tier: (tier.width * tier.height, tier.identifier)) + + +class WebRTCController(StreamController[WebRTCSession]): + """The Camera WebRTC Stream Management service of one sensor.""" + + kind = "WebRTC" + + def __init__( + self, + camera: Camera, + multi_tier: MultiTierController, + config: WebRTCConfig, + ) -> None: + super().__init__( + camera, + multi_tier.sensor, + config.hook_timeout, + config.max_sessions, + config.max_sessions + if config.max_sessions_per_connection is None + else config.max_sessions_per_connection, + ) + self.config = config + self.delegate = config.delegate + self.audio_tier = multi_tier.config.audio + self.video_tier = remote_tier(multi_tier.tiers, config.remote_quality) + service = self.service = camera.add_preload_service( + "CameraWebRTCStreamManagement", unique_id=str(self.sensor.sensor_uuid) + ) + # The WebRTC service goes with the multi-tier service of its sensor. + service.add_linked_service(multi_tier.service) + multi_tier.service.add_linked_service(service) + # The same tiers as the multi-tier service. + for name, source in ( + ("SensorUUID", "SensorUUID"), + ("WebRTCSupportedVideoStreamTiers", "SupportedVideoStreamTiers"), + ("WebRTCSupportedAudioStreamTiers", "SupportedAudioStreamTiers"), + ): + value = multi_tier.service.get_characteristic(source).value + service.configure_char(name, value=value) + self.streaming_enabled = service.configure_char("StreamingEnabled", value=True) + + def refusal(self) -> hksv3.WebRTCSolicitOfferStatus | None: + """Why the sensor may not stream now, None if it may.""" + if not self.camera.homekit_camera_active: + return hksv3.WebRTCSolicitOfferStatus.PRIVACY_MODE_ACTIVE + if not (self.camera.streaming_allowed and bool(self.streaming_enabled.value)): + return hksv3.WebRTCSolicitOfferStatus.ERROR + return None + + def update_status(self) -> None: + """End the sessions if the sensor may no longer stream.""" + if self.refusal() is not None: + self._end_sessions("streaming was turned off") + + @property + def _delegate(self) -> WebRTCDelegate: + # Only a controller with a delegate has sessions. + return cast("WebRTCDelegate", self.delegate) + + async def _release_hook(self, session: WebRTCSession) -> None: + await self._delegate.handle_end(session) diff --git a/tests/conftest.py b/tests/conftest.py index cf5c7e7a..5f041320 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ """Test fictures and mocks.""" import asyncio -from collections.abc import Callable, Iterator +from collections.abc import AsyncIterator, Callable, Iterator +import pathlib from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 @@ -10,7 +11,10 @@ from pyhap import hksv3 from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import Camera +from pyhap.camera_multi_tier import MultiTierDelegate from pyhap.camera_stream import StreamController, StreamSession +from pyhap.camera_webrtc import WebRTCDelegate from pyhap.loader import Loader @@ -75,6 +79,26 @@ def add_job(self, target: Callable[..., Any], *args: Any) -> None: hksv3.VideoStreamCapability(UUID(int=3), 4, 640, 360, 15, 180, 190), ], ) +STREAM_CAMERA_AID = 2 +# The classic RTP stream management service the 17.99 services need. +STREAM_VIDEO = { + "codec": {"profiles": [b"\x01"], "levels": [b"\x02"]}, + "resolutions": [[640, 360, 30]], +} +STREAM_AUDIO = {"codecs": [{"type": "OPUS", "samplerate": 24}]} + + +@pytest.fixture(name="stream_driver") +async def stream_driver_fixture( + tmp_path: pathlib.Path, +) -> AsyncIterator[AccessoryDriver]: + """A driver on the running loop for HKSV 17.99 stream tests.""" + with patch("pyhap.accessory_driver.AccessoryDriver.async_persist"): + yield AccessoryDriver( + loop=asyncio.get_running_loop(), + listen_address="127.0.0.1", + persist_file=str(tmp_path / "accessory.state"), + ) @pytest.fixture @@ -124,3 +148,51 @@ def _add(client_addr: Any) -> StreamSession: return session return _add + + +@pytest.fixture +def multi_tier_delegate() -> AsyncMock: + """A multi-tier delegate whose hooks succeed.""" + return AsyncMock(spec=MultiTierDelegate) + + +@pytest.fixture +def webrtc_delegate() -> AsyncMock: + """A WebRTC delegate that offers with SFrame and answers reoffers.""" + delegate = AsyncMock(spec=WebRTCDelegate) + delegate.handle_solicit_offer.return_value = hksv3.WebRTCOffer( + "v=0\r\n", hksv3.SFrameKey(bytes(32), 7) + ) + delegate.handle_reoffer.return_value = hksv3.WebRTCReofferAnswer( + "v=0\r\na=answer\r\n" + ) + return delegate + + +@pytest.fixture +def stream_camera( + stream_driver: AccessoryDriver, + multi_tier_delegate: AsyncMock, + webrtc_delegate: AsyncMock, +) -> Callable[..., Camera]: + """Build a camera with the multi-tier and WebRTC services and delegates.""" + + def _make(**options: Any) -> Camera: + return Camera( + { + "address": "127.0.0.1", + "video": STREAM_VIDEO, + "audio": STREAM_AUDIO, + "sensors": [STREAM_SENSOR], + "global_operating_mode": ["ManuallyDisabled"], + "recording": True, + "multi_tier": {"delegate": multi_tier_delegate}, + "webrtc": {"delegate": webrtc_delegate}, + **options, + }, + stream_driver, + "Cam", + aid=STREAM_CAMERA_AID, + ) + + return _make diff --git a/tests/test_camera_multi_tier.py b/tests/test_camera_multi_tier.py new file mode 100644 index 00000000..a429f0f9 --- /dev/null +++ b/tests/test_camera_multi_tier.py @@ -0,0 +1,214 @@ +"""Tests for the HKSV 17.99 multi-tier RTP streaming of pyhap.camera.""" + +import asyncio +from collections.abc import Callable +from typing import Any, cast +from unittest.mock import patch +from uuid import UUID + +import pytest + +from pyhap import hksv3 +from pyhap.camera import Camera +from pyhap.camera_multi_tier import ( + MultiTierController, +) +from pyhap.camera_webrtc import WebRTCController +from pyhap.util import base64_to_bytes, to_base64_str + +MULTI_TIER = "CameraMultiTierRTPStreamManagement" +WEBRTC = "CameraWebRTCStreamManagement" +SENSOR = UUID("00112233-4455-6677-8899-aabbccddeeff") +SESSION = UUID("acccc6cc-1505-6345-158d-a974b4509006") +CLIENT = ("192.168.1.114", 50000) +OTHER = ("192.168.1.115", 50000) +KEY = bytes(range(16)) +SALT = bytes(range(14)) +HIGH = hksv3.VideoStreamTier(1, 2, 1700, 1920, 1080, 30, 1800) +MEDIUM = hksv3.VideoStreamTier(2, 3, 768, 1280, 720, 30, 800) + + +def _controller(camera: Camera) -> MultiTierController: + return camera.multi_tier_controllers[SENSOR] + + +def _char(camera: Camera, service: str, name: str) -> Any: + found = camera.get_service(service) + assert found is not None + return found.get_characteristic(name) + + +# Services and options + + +async def test_services(stream_camera: Callable[..., Camera]) -> None: + """Test the advertised tiers, payload types and linked services.""" + camera = stream_camera() + multi_tier = camera.get_service(MULTI_TIER) + webrtc = camera.get_service(WEBRTC) + transport = camera.get_service("DataStreamTransportManagement") + assert multi_tier is not None + assert webrtc is not None + video = base64_to_bytes( + _char(camera, MULTI_TIER, "SupportedVideoStreamTiers").value + ) + assert video[:6] == bytes.fromhex("010102 020163") + assert ( + video + == hksv3.SupportedVideoStreamTiers( + hksv3.VideoCodec.H265, 99, _controller(camera).tiers + ).encode() + ) + audio = base64_to_bytes( + _char(camera, MULTI_TIER, "SupportedAudioStreamTiers").value + ) + assert audio == bytes.fromhex( + "010103 02016e 0318 010401000000 0204c05d0000 030104 040102 050114 060101" + ) + assert _char(camera, WEBRTC, "WebRTCSupportedVideoStreamTiers").value == ( + to_base64_str(video) + ) + assert _char(camera, WEBRTC, "WebRTCSupportedAudioStreamTiers").value == ( + to_base64_str(audio) + ) + assert base64_to_bytes(_char(camera, WEBRTC, "SensorUUID").value) == SENSOR.bytes + assert _char(camera, MULTI_TIER, "StatusActive").value is True + assert multi_tier.linked_services == [webrtc, transport] + assert webrtc.linked_services == [multi_tier, transport] + assert camera.services.index(multi_tier) < camera.services.index(webrtc) + + +async def test_rtp_services_come_after(stream_camera: Callable[..., Camera]) -> None: + """Test the RTP stream management services follow with a new unique_id.""" + camera = stream_camera() + rtp = camera.get_service("CameraRTPStreamManagement") + multi_tier = camera.get_service(MULTI_TIER) + assert rtp is not None + assert rtp.unique_id == "rtp-0" + assert camera.services.index(cast(Any, multi_tier)) < camera.services.index(rtp) + + +@pytest.mark.parametrize( + ("options", "match"), + [ + ({"webrtc": None}, "need each other"), + ({"multi_tier": None}, "need each other"), + ({"video": None}, "need the video and audio"), + ({"video": {}}, "need the video and audio"), + ({"audio": None}, "need the video and audio"), + ({"audio": {}}, "need the video and audio"), + ({"stream_count": 0}, "need the video and audio"), + ({"sensors": []}, "need the sensors"), + ({"global_operating_mode": None}, "need the sensors"), + ({"recording": False}, "need the sensors"), + ({"multi_tier": {"start_stream_cmd": "ffmpeg"}}, "Unknown multi_tier"), + ({"webrtc": {"offer_timeout": 8}}, "Unknown webrtc option"), + ({"multi_tier": {"max_sessions": 4}}, "at least 5"), + ({"webrtc": {"max_sessions": 5}}, "at least 6"), + ({"multi_tier": {"max_sessions_per_connection": 0}}, "must be 1 to"), + ({"webrtc": {"max_sessions_per_connection": 7}}, "must be 1 to"), + ({"multi_tier": {"hook_timeout": 9}}, "hook_timeout"), + ({"webrtc": {"remote_quality": 5}}, "remote_quality"), + ({"multi_tier": {"audio": None}}, "exactly one audio tier"), + ], +) +async def test_invalid_options( + stream_camera: Callable[..., Camera], options: dict[str, Any], match: str +) -> None: + """Test invalid combinations and values raise.""" + with pytest.raises(ValueError, match=match): + stream_camera(**options) + + +async def test_tier_warnings( + stream_camera: Callable[..., Camera], caplog: pytest.LogCaptureFixture +) -> None: + """Test bit rates beyond the guide are logged, not refused.""" + sensor = hksv3.SensorConfiguration( + SENSOR, + 1920, + 1080, + video_streams=[ + hksv3.VideoStreamCapability(UUID(int=1), 2, 1920, 1080, 30, 1700, 2500), + hksv3.VideoStreamCapability(UUID(int=2), 3, 1280, 720, 30, 768, 800), + hksv3.VideoStreamCapability(UUID(int=3), 4, 640, 360, 15, 180, 190), + ], + ) + stream_camera(sensors=[sensor]) + assert "1920x1080@30: peak above the guide's 1800 kbps" in caplog.text + + +async def test_status_active(stream_camera: Callable[..., Camera]) -> None: + """Test Status Active follows the global operating mode and the service.""" + camera = stream_camera() + camera.set_manually_disabled(True) + assert _char(camera, MULTI_TIER, "StatusActive").value is False + camera.set_manually_disabled(False) + _char(camera, MULTI_TIER, "StreamingEnabled").client_update_value(False) + assert _char(camera, MULTI_TIER, "StatusActive").value is True + await asyncio.sleep(0) + assert _char(camera, MULTI_TIER, "StatusActive").value is False + + +async def test_streaming_enabled_persisted( + stream_camera: Callable[..., Camera], +) -> None: + """Test the services' Streaming Enabled survive a restart.""" + camera = stream_camera() + _char(camera, WEBRTC, "StreamingEnabled").client_update_value(False) + restored = stream_camera() + assert _char(restored, WEBRTC, "StreamingEnabled").value is False + assert _char(restored, MULTI_TIER, "StreamingEnabled").value is True + + +async def test_camera_forwards_to_controllers( + stream_camera: Callable[..., Camera], +) -> None: + """Test the camera's session actions reach each controller.""" + camera = stream_camera() + controllers = [ + *camera.multi_tier_controllers.values(), + *camera.webrtc_controllers.values(), + ] + with ( + patch.object(MultiTierController, "close") as close, + patch.object(MultiTierController, "client_disconnected") as disconnected, + patch.object(MultiTierController, "end_session", return_value=True) as end, + patch.object(MultiTierController, "end_all_sessions") as end_all, + ): + await camera.stop() + camera.client_disconnected(CLIENT) + assert await camera.end_session(SESSION) is True + await camera.end_all_sessions() + close.assert_awaited_once() + disconnected.assert_called_once_with(CLIENT) + end.assert_awaited_once_with(SESSION) + end_all.assert_awaited_once() + assert len(controllers) == 2 + + +@pytest.mark.parametrize( + ("multi_tier_ended", "webrtc_ended", "expected"), + [(True, True, True), (False, True, True), (False, False, False)], +) +async def test_camera_ends_the_id_in_each_controller( + stream_camera: Callable[..., Camera], + multi_tier_ended: bool, + webrtc_ended: bool, + expected: bool, +) -> None: + """Test the camera ends a session ID in every controller, not the first only.""" + camera = stream_camera() + with ( + patch.object( + MultiTierController, "end_session", return_value=multi_tier_ended + ) as multi_tier_end, + patch.object( + WebRTCController, "end_session", return_value=webrtc_ended + ) as webrtc_end, + ): + assert await camera.end_session(SESSION) is expected + await camera.stop() + + multi_tier_end.assert_awaited_once_with(SESSION) + webrtc_end.assert_awaited_once_with(SESSION) diff --git a/tests/test_camera_webrtc.py b/tests/test_camera_webrtc.py new file mode 100644 index 00000000..3659a02d --- /dev/null +++ b/tests/test_camera_webrtc.py @@ -0,0 +1,22 @@ +"""Tests for the HKSV 17.99 WebRTC streaming of pyhap.camera.""" + +from uuid import UUID + +from pyhap import hksv3 +from pyhap.camera_webrtc import remote_tier + +WEBRTC = "CameraWebRTCStreamManagement" +SENSOR = UUID("00112233-4455-6677-8899-aabbccddeeff") +CLIENT = ("192.168.1.114", 50000) +OTHER = ("192.168.1.115", 50000) +MEDIUM = hksv3.VideoStreamTier(2, 3, 768, 1280, 720, 30, 800) +LOW = hksv3.VideoStreamTier(3, 4, 180, 640, 360, 15, 190) +ANSWER = "v=0\r\na=setup:active\r\n" +KEY = hksv3.SFrameKey(bytes(32), 7) + + +def test_remote_tier(stream_sensor: hksv3.SensorConfiguration) -> None: + """Test the remote tier is the smallest of a quality, else the smallest.""" + tiers = hksv3.tiers_from_sensor(stream_sensor) + assert remote_tier(tiers, hksv3.VideoQuality.MEDIUM) == MEDIUM + assert remote_tier(tiers, hksv3.VideoQuality.HIGHEST) == LOW From f46b75f938eeee2fb2c834440f46aa8e9f85a684 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:42:38 +0100 Subject: [PATCH 29/56] Set up, start and end HKSV 17.99 multi-tier RTP sessions A controller sets up a multi-tier session with Setup Endpoints and starts, changes or ends its stream with RTP Streaming Control. Setup Endpoints: - Only the canonical address of the announced IP version is accepted, and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode, and one while streaming is not allowed, without a delegate or for a session of another connection, answers an error. Beyond max_sessions, or max_sessions_per_connection for the writing connection, it is busy. - The session is registered before the delegate prepares it, so a closed connection or streaming turned off ends it too. A failed or slow prepare answers an error and the delegate still releases the session. - The answer has the accessory's address on the controller's connection, in the controller's IP version, as in HAP-NodeJS #1132. The delegate may change it. The driver's new local_address() finds it. Only the writing connection reads the answer back. - Setting a session up again releases the former one first. If that release fails or overruns the deadline, streaming turned off, or another write set the same ID up or took the room meanwhile, it answers an error or busy. RTP Streaming Control: - Only the connection that set the session up controls it. - A start picks advertised tiers and calls handle_start(session, video_tier, audio_tier), the audio tier is None for video only. A start of a started session calls handle_reconfigure(), which keeps the SSRCs and SRTP keys of the running stream. - An end releases the session once. A session that is not started within setup_timeout (30 s) ends. A started session ends after idle_timeout (30 s) unless the delegate calls session.keep_alive(). idle_timeout=None turns that off. open_rtcp_monitor() does that for the controller's RTCP, as homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the accessory's video port and keeps the session alive for each datagram from the controller's address. A former session with the same ID keeps nothing alive. --- pyhap/accessory_driver.py | 21 + pyhap/camera.py | 13 + pyhap/camera_multi_tier.py | 353 ++++++++++++++- tests/test_camera_multi_tier.py | 745 +++++++++++++++++++++++++++++++- 4 files changed, 1127 insertions(+), 5 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 18a34d27..74fc9e99 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -24,6 +24,7 @@ from concurrent.futures import ThreadPoolExecutor import hashlib import inspect +import ipaddress import logging import os import re @@ -1097,6 +1098,26 @@ def derive_session_key( return None return connection.derive_key(salt, info) + def local_address(self, client_addr: Any, ipv6: bool) -> str | None: + """The accessory's address on a controller's connection. + + Returns None without such a connection or if its address is not of + the asked IP version. An IPv4 address mapped into IPv6 counts as + IPv4, a scope ID is dropped. + """ + connection = self.http_server.connections.get(client_addr) + transport = getattr(connection, "transport", None) + sockname = transport.get_extra_info("sockname") if transport else None + if not sockname: + return None + try: + address = ipaddress.ip_address(str(sockname[0]).split("%", 1)[0]) + except ValueError: + return None + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + return str(address) if address.version == (6 if ipv6 else 4) else None + def set_characteristics( self, chars_query: dict[str, Any], client_addr: tuple[str, int] ) -> dict[str, Any] | None: diff --git a/pyhap/camera.py b/pyhap/camera.py index af83ae63..f25246ab 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -873,6 +873,19 @@ def _update_stream_status(self) -> None: for controller in self._stream_controllers(): controller.update_status() + def stream_address_for(self, client_addr: Any, is_ipv6: bool) -> str | None: + """The address to stream from to a controller, in the given IP version. + + The accessory's address on the controller's connection, else the + ``address`` option if it has that version. + """ + address = self.driver.local_address(client_addr, is_ipv6) + if address is not None: + return address + if (self.stream_address_isv6 == b"\x01") == is_ipv6: + return str(self.stream_address) + return None + async def end_session(self, session_id: UUID) -> bool: """End a multi-tier or WebRTC session (17.99) on the camera's side. diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index ea2fcc3d..00e297cb 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -13,36 +13,48 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping from dataclasses import dataclass, field import logging +import secrets from typing import TYPE_CHECKING, Any, Protocol, cast from pyhap import hksv3 +from pyhap.camera import parse_setup_address from pyhap.camera_stream import ( DEFAULT_HOOK_TIMEOUT, + HookFailed, + SessionState, StreamController, StreamSession, check_hook_timeout, config_from_option, + failure_status, ) -from pyhap.util import to_base64_str +from pyhap.util import base64_to_bytes, to_base64_str if TYPE_CHECKING: from pyhap.camera import Camera __all__ = [ + "DEFAULT_IDLE_TIMEOUT", "MULTI_TIER_MIN_SESSIONS", "MultiTierConfig", "MultiTierController", "MultiTierDelegate", "MultiTierSession", + "RtcpMonitor", + "open_rtcp_monitor", ] logger = logging.getLogger(__name__) # Multi-tier sessions per sensor the guide requires at least. MULTI_TIER_MIN_SESSIONS = 5 +# Seconds a started session lives without keep_alive(). +DEFAULT_IDLE_TIMEOUT = 30.0 +_SRTP_SUITE = hksv3.SRTPCryptoSuite.AES_CM_128_HMAC_SHA1_80 @dataclass(eq=False, kw_only=True) @@ -77,6 +89,72 @@ class MultiTierSession(StreamSession): video_tier: hksv3.VideoStreamTier | None = None audio_tier: hksv3.AudioStreamTier | None = None + def keep_alive(self) -> None: + """Restart the idle timeout of the started session. + + Call it when the controller is still there, e.g. for each of its RTCP + packets as :class:`RtcpMonitor` does. Call it in the event loop. + """ + controller = self._controller + if isinstance(controller, MultiTierController): + controller.keep_alive(self) + + +class RtcpMonitor(asyncio.DatagramProtocol): + """Keeps a session alive while its controller sends RTCP. + + Listens on the accessory's video port of the session, where the + controller sends its RTCP. Each datagram from the controller's address + calls :meth:`MultiTierSession.keep_alive`, without decrypting it. Others + are ignored. Open it with :func:`open_rtcp_monitor`. + """ + + def __init__(self, session: MultiTierSession) -> None: + self.session = session + self.port = 0 + self._transport: asyncio.DatagramTransport | None = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self._transport = cast(asyncio.DatagramTransport, transport) + self.port = int(transport.get_extra_info("sockname")[1]) + + def datagram_received(self, data: bytes, addr: tuple[Any, ...]) -> None: + # The controller's address has no scope ID, a received one may. + if str(addr[0]).split("%", 1)[0] == self.session.address: + self.session.keep_alive() + + @property + def closed(self) -> bool: + return self._transport is None or self._transport.is_closing() + + def close(self) -> None: + """Close the socket, e.g. in ``handle_end``.""" + if self._transport is not None: + self._transport.close() + + +async def open_rtcp_monitor(session: MultiTierSession) -> RtcpMonitor: + """Open an :class:`RtcpMonitor` for ``session``, e.g. in ``handle_prepare``. + + Binds a free UDP port of the controller's IP version and announces it as + the accessory's video port, ``session.accessory_video_port``. The stream + is then sent from another port, with its RTCP to the controller's video + port; for ffmpeg, an ``srtp://`` output without ``localrtpport`` and with + ``rtcpport`` set to ``session.video_port``. Close the monitor when the + session ends. + + :raises OSError: If no port can be bound. + """ + loop = asyncio.get_running_loop() + # Any interface, as the stream's own ports. Only the controller's + # datagrams count. + any_address = "::" if session.is_ipv6 else "0.0.0.0" # noqa: S104 + _, monitor = await loop.create_datagram_endpoint( + lambda: RtcpMonitor(session), local_addr=(any_address, 0) + ) + session.accessory_video_port = monitor.port + return monitor + class MultiTierDelegate(Protocol): """Sends the media of multi-tier RTP sessions. @@ -137,7 +215,11 @@ class MultiTierConfig: ``delegate`` sends the media. ``audio`` is the one audio tier the guide allows. ``max_sessions`` per sensor is at least five, as the guide requires, and one connection has at most ``max_sessions_per_connection`` - of them. A delegate hook gets ``hook_timeout`` seconds. + of them. A delegate hook gets ``hook_timeout`` seconds. A session must + start within ``setup_timeout`` seconds of its Setup Endpoints answer. + A started session ends unless :meth:`MultiTierSession.keep_alive` is + called at least every ``idle_timeout`` seconds, e.g. for each RTCP packet + of the controller; None turns that off. """ delegate: MultiTierDelegate | None = None @@ -145,6 +227,8 @@ class MultiTierConfig: max_sessions: int = MULTI_TIER_MIN_SESSIONS max_sessions_per_connection: int = 3 hook_timeout: float = DEFAULT_HOOK_TIMEOUT + setup_timeout: float = 30.0 + idle_timeout: float | None = DEFAULT_IDLE_TIMEOUT @classmethod def from_option(cls, value: MultiTierConfig | Mapping[str, Any]) -> MultiTierConfig: @@ -167,6 +251,10 @@ def __post_init__(self) -> None: if not isinstance(self.audio, hksv3.AudioStreamTier): raise ValueError("multi_tier audio must be exactly one audio tier") hksv3.check_audio_tier(self.audio) + if self.setup_timeout <= 0 or ( + self.idle_timeout is not None and self.idle_timeout <= 0 + ): + raise ValueError("multi_tier timeouts must be positive") def supported_audio_tiers(audio: hksv3.AudioStreamTier) -> bytes: @@ -219,6 +307,14 @@ def __init__( ) self.streaming_enabled = service.configure_char("StreamingEnabled", value=True) self._status_active = service.get_characteristic("StatusActive") + setup_endpoints = service.configure_char( + "SetupEndpoints", setter_callback=self._set_setup_endpoints + ) + # The answer has the session's SRTP keys, only its controller reads it. + setup_endpoints.read_back_response = True + service.configure_char( + "RTPStreamingControl", setter_callback=self._set_streaming_control + ) @property def active(self) -> bool: @@ -232,6 +328,23 @@ def update_status(self) -> None: if not active: self._end_sessions("streaming was turned off") + def keep_alive(self, session: MultiTierSession) -> None: + """Restart the ``idle_timeout`` of a started session. + + A former session with the same ID keeps nothing alive. See + :meth:`MultiTierSession.keep_alive`. + """ + if ( + self._sessions.get(session.id) is session + and session.state is SessionState.STARTED + ): + self._set_timer( + session, + self.config.idle_timeout, + "No stream activity", + SessionState.STARTED, + ) + @property def _delegate(self) -> MultiTierDelegate: # Only a controller with a delegate has sessions. @@ -239,3 +352,239 @@ def _delegate(self) -> MultiTierDelegate: async def _release_hook(self, session: MultiTierSession) -> None: await self._delegate.handle_end(session) + + async def _set_setup_endpoints( + self, value: str, sender_client_addr: Any = None + ) -> Any: + """Set up a session, only the writing connection reads the answer.""" + deadline = self._deadline() + try: + request = hksv3.SetupEndpoints.decode(base64_to_bytes(value)) + except ValueError: + logger.warning("Ignoring a malformed Setup Endpoints write") + status = hksv3.SetupEndpointsStatus.ERROR + return to_base64_str(hksv3.SetupEndpointsResponse(None, status).encode()) + result = await self._setup(request, sender_client_addr, deadline) + if isinstance(result, MultiTierSession): + return to_base64_str(_answer(result).encode()) + response = hksv3.SetupEndpointsResponse(request.session_id, result) + return to_base64_str(response.encode()) + + async def _setup( + self, + request: hksv3.SetupEndpoints, + client_addr: Any, + deadline: float, + ) -> MultiTierSession | hksv3.SetupEndpointsStatus: + session_id = request.session_id + endpoint = request.address + error = hksv3.SetupEndpointsStatus.ERROR + try: + address = parse_setup_address( + endpoint.address.encode("utf-8"), endpoint.is_ipv6 + ) + _check_srtp(request.video_srtp) + _check_srtp(request.audio_srtp) + except ValueError as ex: + logger.warning("[%s] Refusing the endpoints: %s", session_id, ex) + return error + logger.debug( + "[%s] Setup Endpoints: %s, video port %d, audio port %d", + session_id, + address, + endpoint.video_port, + endpoint.audio_port, + ) + existing = self._owned(session_id, client_addr) + if existing is None and session_id in self._sessions: + return error + if not self.active: + logger.warning("[%s] Streaming is turned off, refusing it", session_id) + return error + if existing is None and (no_room := self._no_room(client_addr)): + logger.warning("[%s] %s", session_id, no_room) + return hksv3.SetupEndpointsStatus.BUSY + if self.delegate is None: + logger.warning("[%s] No multi-tier delegate, refusing", session_id) + return error + accessory_address = self.camera.stream_address_for( + client_addr, endpoint.is_ipv6 + ) + if accessory_address is None: + logger.warning( + "[%s] No local address of the controller's IP version", session_id + ) + return error + if existing is not None: + # Set up again: the delegate releases the former session first. + if not await self._end_by_request(existing, deadline): + logger.warning( + "[%s] The former session was not released, refusing", + session_id, + ) + return error + # Meanwhile another write may have set up the ID or taken the room. + if session_id in self._sessions: + logger.warning("[%s] Set up again meanwhile, refusing", session_id) + return error + if not self.active: + logger.warning("[%s] Streaming is turned off, refusing it", session_id) + return error + if no_room := self._no_room(client_addr): + logger.warning("[%s] %s", session_id, no_room) + return hksv3.SetupEndpointsStatus.BUSY + session = MultiTierSession( + id=session_id, + sensor=self.sensor, + client_addr=client_addr, + address=address, + is_ipv6=endpoint.is_ipv6, + video_port=endpoint.video_port, + audio_port=endpoint.audio_port, + video_srtp_key=request.video_srtp.master_key, + video_srtp_salt=request.video_srtp.master_salt, + audio_srtp_key=request.audio_srtp.master_key, + audio_srtp_salt=request.audio_srtp.master_salt, + video_ssrc=_ssrc(), + audio_ssrc=_ssrc(), + accessory_address=accessory_address, + accessory_video_port=endpoint.video_port, + accessory_audio_port=endpoint.audio_port, + ) + while session.audio_ssrc == session.video_ssrc: + session.audio_ssrc = _ssrc() + # Registered before prepare, so a disconnect or the gate ends it too. + self._add(session) + try: + await self._run_hook( + session, + "prepare", + self.delegate.handle_prepare(session), + deadline, + end_on_failure=True, + first=True, + ) + except HookFailed as err: + return failure_status(hksv3.SetupEndpointsStatus, err.status, error) + session.state = SessionState.PREPARED + self._set_timer( + session, + self.config.setup_timeout, + "Not started in time", + SessionState.PREPARED, + ) + return session + + async def _set_streaming_control( + self, value: str, sender_client_addr: Any = None + ) -> str: + """Start, change or end a stream, the write response is the status.""" + deadline = self._deadline() + try: + request = hksv3.RTPStreamingControl.decode(base64_to_bytes(value)) + except ValueError: + logger.warning("Ignoring a malformed RTP Streaming Control write") + status = hksv3.encode_streaming_status(None, hksv3.RTPStreamingStatus.ERROR) + return to_base64_str(status) + logger.debug("RTP Streaming Control: %s", request) + result = await self._streaming_control(request, sender_client_addr, deadline) + logger.debug("[%s] RTP Streaming Control: %s", request.session_id, result) + return to_base64_str(hksv3.encode_streaming_status(request.session_id, result)) + + async def _streaming_control( + self, + request: hksv3.RTPStreamingControl, + client_addr: Any, + deadline: float, + ) -> hksv3.RTPStreamingStatus: + status = hksv3.RTPStreamingStatus + session = self._owned(request.session_id, client_addr) + if session is None: + return status.UNKNOWN_SESSION + if request.command == hksv3.RTPStreamingCommand.END: + if session.state is not SessionState.STARTED: + self._end(session, "ended before it started") + return status.NO_SUCH_STREAM + ended = await self._end_by_request(session, deadline) + return status.SUCCESS if ended else status.ERROR + if request.command != hksv3.RTPStreamingCommand.START: + return status.ERROR + if session.state is SessionState.NEW or session.busy: + return status.BUSY + if not self.active: + logger.warning("[%s] Streaming is turned off, refusing it", session.id) + return status.ERROR + tiers = self._tiers(request) + if tiers is None: + return status.ERROR + session.controller_video_ssrc = request.video_ssrc + session.controller_audio_ssrc = request.audio_ssrc + started = session.state is SessionState.STARTED + if started: + hook = self._delegate.handle_reconfigure(session, *tiers) + else: + hook = self._delegate.handle_start(session, *tiers) + try: + await self._run_hook( + session, "reconfigure" if started else "start", hook, deadline + ) + except HookFailed as err: + return failure_status(status, err.status, status.ERROR) + session.video_tier, session.audio_tier = tiers + session.state = SessionState.STARTED + self.keep_alive(session) + return status.SUCCESS + + def _tiers( + self, request: hksv3.RTPStreamingControl + ) -> tuple[hksv3.VideoStreamTier, hksv3.AudioStreamTier | None] | None: + """The tiers a start picked, None if they are not advertised.""" + video = next( + (tier for tier in self.tiers if tier.identifier == request.video_tier), + None, + ) + audio = self.config.audio + if video is None or request.video_ssrc is None: + return None + if request.audio_tier is None: + return video, None + if request.audio_tier != audio.identifier or request.audio_ssrc is None: + return None + return video, audio + + +def _ssrc() -> int: + # Up to 2^31 - 1, ffmpeg takes the SSRC as a signed int. + return secrets.randbelow(2**31 - 1) + 1 + + +def _check_srtp(params: hksv3.SRTPParameters) -> None: + """Accept only AES_CM_128_HMAC_SHA1_80 keys, the guide allows nothing else.""" + if ( + params.crypto_suite != _SRTP_SUITE + or len(params.master_key) != 16 + or len(params.master_salt) != 14 + ): + raise ValueError("only AES_CM_128_HMAC_SHA1_80 is supported") + + +def _answer(session: MultiTierSession) -> hksv3.SetupEndpointsResponse: + """The successful Setup Endpoints answer of ``session``.""" + return hksv3.SetupEndpointsResponse( + session.id, + hksv3.SetupEndpointsStatus.SUCCESS, + hksv3.EndpointAddress( + session.accessory_address, + session.is_ipv6, + session.accessory_video_port, + session.accessory_audio_port, + ), + hksv3.SRTPParameters( + _SRTP_SUITE, session.video_srtp_key, session.video_srtp_salt + ), + hksv3.SRTPParameters( + _SRTP_SUITE, session.audio_srtp_key, session.audio_srtp_salt + ), + session.video_ssrc, + session.audio_ssrc, + ) diff --git a/tests/test_camera_multi_tier.py b/tests/test_camera_multi_tier.py index a429f0f9..8529c360 100644 --- a/tests/test_camera_multi_tier.py +++ b/tests/test_camera_multi_tier.py @@ -2,17 +2,22 @@ import asyncio from collections.abc import Callable +import struct from typing import Any, cast -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID import pytest -from pyhap import hksv3 -from pyhap.camera import Camera +from pyhap import hksv3, tlv +from pyhap.camera import SETUP_ADDR_INFO, SETUP_SRTP_PARAM, SETUP_TYPES, Camera from pyhap.camera_multi_tier import ( MultiTierController, + MultiTierSession, + RtcpMonitor, + open_rtcp_monitor, ) +from pyhap.camera_stream import SessionState, StreamError from pyhap.camera_webrtc import WebRTCController from pyhap.util import base64_to_bytes, to_base64_str @@ -41,6 +46,11 @@ def _char(camera: Camera, service: str, name: str) -> Any: # Services and options +async def _hang(*_args: object) -> None: + """Never return, as a hook stuck beyond its timeout.""" + await asyncio.Event().wait() + + async def test_services(stream_camera: Callable[..., Camera]) -> None: """Test the advertised tiers, payload types and linked services.""" camera = stream_camera() @@ -212,3 +222,732 @@ async def test_camera_ends_the_id_in_each_controller( multi_tier_end.assert_awaited_once_with(SESSION) webrtc_end.assert_awaited_once_with(SESSION) + + +# Sessions + + +def _setup_request( + address: bytes = b"192.168.1.114", + is_ipv6: bool = False, + suite: bytes = b"\x00", + key: bytes = KEY, +) -> str: + """A Setup Endpoints write of SESSION from the controller at ``address``.""" + address_tlv = tlv.encode( + SETUP_ADDR_INFO["ADDRESS_VER"], + struct.pack("?", is_ipv6), + SETUP_ADDR_INFO["ADDRESS"], + address, + SETUP_ADDR_INFO["VIDEO_RTP_PORT"], + struct.pack(" dict[bytes, Any]: + """Write Setup Endpoints and decode the answer the writer reads back.""" + controller = _controller(camera) + answer = await controller._set_setup_endpoints(request or _setup_request(), client) + return tlv.decode(answer, from_base64=True) + + +def _session(camera: Camera) -> MultiTierSession: + return _controller(camera).sessions[SESSION] + + +def _control( + command: int, video_tier: int | None = 1, audio_tier: int | None = 1 +) -> str: + """An RTP Streaming Control write for SESSION.""" + data = hksv3.encode_item(1, SESSION.bytes) + hksv3.encode_item(2, bytes([command])) + if video_tier is not None: + data += hksv3.encode_item(3, video_tier.to_bytes(4, "little")) + data += hksv3.encode_item(4, (0x1234).to_bytes(4, "little")) + if audio_tier is not None: + data += hksv3.encode_item(5, audio_tier.to_bytes(4, "little")) + data += hksv3.encode_item(6, (0x5678).to_bytes(4, "little")) + return to_base64_str(data) + + +async def _streaming_control( + camera: Camera, command: int = 2, client: Any = CLIENT, **tiers: Any +) -> int: + """Write RTP Streaming Control and return the status of the answer.""" + controller = _controller(camera) + answer = await controller._set_streaming_control(_control(command, **tiers), client) + status = tlv.decode(base64_to_bytes(answer))[b"\x02"] + return int(status[0]) + + +async def _starts(camera: Camera, count: int) -> list[int]: + """Start SESSION ``count`` times, return the statuses.""" + return [await _streaming_control(camera) for _ in range(count)] + + +def _setup_request_of(session_id: UUID) -> str: + """A Setup Endpoints write like ``_setup_request()`` for ``session_id``.""" + request = base64_to_bytes(_setup_request()) + return to_base64_str(request[:2] + session_id.bytes + request[18:]) + + +async def _setup_many(camera: Camera, count: int) -> list[int]: + """Set up ``count`` sessions with other IDs, return the answer statuses.""" + statuses: list[int] = [] + for index in range(count): + answer = await _setup(camera, _setup_request_of(UUID(int=index + 1))) + statuses.append(answer[SETUP_TYPES["STATUS"]][0]) + return statuses + + +async def test_setup( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a session is prepared and answered from the stream address.""" + camera = stream_camera() + + answer = await _setup(camera) + + session = _session(camera) + assert answer[SETUP_TYPES["STATUS"]] == b"\x00" + assert tlv.decode(answer[SETUP_TYPES["ADDRESS"]]) == { + SETUP_ADDR_INFO["ADDRESS_VER"]: b"\x00", + SETUP_ADDR_INFO["ADDRESS"]: b"127.0.0.1", + SETUP_ADDR_INFO["VIDEO_RTP_PORT"]: struct.pack(" None: + """Test the answer has the accessory's address on the connection.""" + camera = stream_camera() + with patch.object(camera.driver, "local_address", return_value="fd00::10"): + answer = await _setup(camera, _setup_request(b"fd00::1", True)) + address = tlv.decode(answer[SETUP_TYPES["ADDRESS"]]) + assert address[SETUP_ADDR_INFO["ADDRESS_VER"]] == b"\x01" + assert address[SETUP_ADDR_INFO["ADDRESS"]] == b"fd00::10" + + +async def test_prepare_may_change_the_answer( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test the delegate may answer with other addresses and ports.""" + + async def _prepare(session: MultiTierSession) -> None: + session.accessory_address = "192.168.1.20" + session.accessory_video_port = 40000 + + multi_tier_delegate.handle_prepare.side_effect = _prepare + answer = await _setup(stream_camera()) + address = tlv.decode(answer[SETUP_TYPES["ADDRESS"]]) + assert address[SETUP_ADDR_INFO["ADDRESS"]] == b"192.168.1.20" + assert address[SETUP_ADDR_INFO["VIDEO_RTP_PORT"]] == struct.pack(" None: + """Test invalid endpoints answer an error before the delegate sees them.""" + camera = stream_camera() + answer = await _setup(camera, request_value) + assert answer == { + SETUP_TYPES["SESSION_ID"]: SESSION.bytes, + SETUP_TYPES["STATUS"]: b"\x02", + } + assert _controller(camera).sessions == {} + multi_tier_delegate.handle_prepare.assert_not_called() + + +async def test_setup_refused_while_streaming_is_off( + stream_camera: Callable[..., Camera], +) -> None: + """Test no session is set up while streaming is not allowed.""" + camera = stream_camera() + camera.set_manually_disabled(True) + answer = await _setup(camera) + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + + +async def test_setup_without_delegate(stream_camera: Callable[..., Camera]) -> None: + """Test a camera without a delegate refuses sessions.""" + camera = stream_camera(multi_tier={}) + answer = await _setup(camera) + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + + +async def test_setup_busy( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test sessions beyond max_sessions answer busy.""" + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "max_sessions_per_connection": 5} + ) + assert await _setup_many(camera, 6) == [0, 0, 0, 0, 0, 1] + + +async def test_setup_busy_per_connection(stream_camera: Callable[..., Camera]) -> None: + """Test a connection gets three sessions, another one still gets its own.""" + camera = stream_camera() + + assert await _setup_many(camera, 4) == [0, 0, 0, 1] + assert (await _setup(camera, client=OTHER))[SETUP_TYPES["STATUS"]] == b"\x00" + + +async def test_malformed_setup(stream_camera: Callable[..., Camera]) -> None: + """Test a Setup Endpoints write that does not decode answers an error.""" + answer = await _controller(stream_camera())._set_setup_endpoints("AQ==", CLIENT) + assert base64_to_bytes(answer) == bytes.fromhex("020102") + + +@pytest.mark.parametrize( + ("error", "status"), + [ + (RuntimeError(), b"\x02"), + (StreamError(hksv3.SetupEndpointsStatus.BUSY), b"\x01"), + ], +) +async def test_prepare_fails( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + error: Exception, + status: bytes, +) -> None: + """Test a failed prepare answers its status and still releases the session.""" + multi_tier_delegate.handle_prepare.side_effect = error + camera = stream_camera() + + answer = await _setup(camera) + await camera.stop() + + assert answer[SETUP_TYPES["STATUS"]] == status + assert _controller(camera).sessions == {} + multi_tier_delegate.handle_end.assert_awaited_once() + + +async def test_slow_prepare( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a prepare beyond hook_timeout answers an error in time.""" + multi_tier_delegate.handle_prepare.side_effect = _hang + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "hook_timeout": 0.05} + ) + + answer = await _setup(camera) + await camera.stop() + + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + assert "The prepare hook timed out" in caplog.text + multi_tier_delegate.handle_end.assert_awaited_once() + + +async def test_setup_again( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test setting a session up again releases the former one first.""" + camera = stream_camera() + await _setup(camera) + first = _session(camera) + + answer = await _setup(camera) + + assert answer[SETUP_TYPES["STATUS"]] == b"\x00" + multi_tier_delegate.handle_end.assert_awaited_once_with(first) + assert _session(camera) is not first + + +@pytest.mark.parametrize("client", [CLIENT, OTHER], ids=["same", "other"]) +async def test_setup_again_concurrently( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + client: Any, +) -> None: + """Test a write that sets the ID up during a set up again wins. + + The set up again releases the former session and then finds the ID + taken, so no prepared session is lost: each one is released. + """ + camera = stream_camera() + await _setup(camera) + + again, other = await asyncio.gather(_setup(camera), _setup(camera, client=client)) + session = _session(camera) + await camera.stop() + + assert again[SETUP_TYPES["STATUS"]] == b"\x02" + assert other[SETUP_TYPES["STATUS"]] == b"\x00" + assert session.client_addr == client + assert multi_tier_delegate.handle_prepare.await_count == 2 + assert ( + multi_tier_delegate.handle_end.await_args_list + == multi_tier_delegate.handle_prepare.await_args_list + ) + + +async def test_setup_again_finds_no_room( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a set up again is busy when a new session took its room meanwhile.""" + camera = stream_camera() + await _setup(camera) + await _setup_many(camera, 2) + + again, new = await asyncio.gather( + _setup(camera), _setup(camera, _setup_request_of(UUID(int=3))) + ) + sessions = dict(_controller(camera).sessions) + await camera.stop() + + assert again[SETUP_TYPES["STATUS"]] == b"\x01" + assert new[SETUP_TYPES["STATUS"]] == b"\x00" + assert sorted(sessions) == [UUID(int=1), UUID(int=2), UUID(int=3)] + assert multi_tier_delegate.handle_end.await_count == 4 + + +@pytest.fixture +def end_gate(multi_tier_delegate: AsyncMock) -> asyncio.Event: + """Make ``handle_end`` wait for the returned event. + + ``handle_end.waiting`` is set once it waits. + """ + gate = asyncio.Event() + waiting = asyncio.Event() + + async def _end(_session: MultiTierSession) -> None: + waiting.set() + await gate.wait() + + multi_tier_delegate.handle_end.side_effect = _end + multi_tier_delegate.handle_end.waiting = waiting + return gate + + +async def test_setup_again_turned_off_meanwhile( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + end_gate: asyncio.Event, +) -> None: + """Test a set up again is refused when streaming turned off during the release.""" + camera = stream_camera() + await _setup(camera) + again = asyncio.ensure_future(_setup(camera)) + await multi_tier_delegate.handle_end.waiting.wait() + + camera.set_manually_disabled(True) + end_gate.set() + answer = await again + await camera.stop() + + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + multi_tier_delegate.handle_prepare.assert_awaited_once() + + +async def test_setup_again_release_too_slow( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + end_gate: asyncio.Event, +) -> None: + """Test a set up again answers an error when the former release overruns.""" + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "hook_timeout": 0.05} + ) + await _setup(camera) + + answer = await _setup(camera) + # The former session ended and no new one took its place. + assert _controller(camera).sessions == {} + end_gate.set() + await camera.stop() + + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + multi_tier_delegate.handle_prepare.assert_awaited_once() + + +async def test_setup_of_another_controller( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test another connection cannot take a session over.""" + camera = stream_camera() + await _setup(camera) + session = _session(camera) + + answer = await _setup(camera, client=OTHER) + + assert answer[SETUP_TYPES["STATUS"]] == b"\x02" + assert _session(camera) is session + assert await _streaming_control(camera, client=OTHER) == 1 + + +async def test_start( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a start streams the picked tiers with the session's SSRCs.""" + camera = stream_camera() + await _setup(camera) + session = _session(camera) + + assert await _streaming_control(camera) == 0 + + multi_tier_delegate.handle_start.assert_awaited_once_with( + session, HIGH, hksv3.DEFAULT_AUDIO_TIER + ) + assert session.state is SessionState.STARTED + assert session.video_tier == HIGH + assert session.controller_video_ssrc == 0x1234 + assert session.controller_audio_ssrc == 0x5678 + + +async def test_start_video_only( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a start without an audio tier sends no audio.""" + camera = stream_camera() + await _setup(camera) + assert await _streaming_control(camera, video_tier=2, audio_tier=None) == 0 + multi_tier_delegate.handle_start.assert_awaited_once_with( + _session(camera), MEDIUM, None + ) + + +@pytest.mark.parametrize( + "tiers", + [{"video_tier": 9}, {"video_tier": None}, {"audio_tier": 2}], +) +async def test_start_unknown_tiers( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + tiers: dict[str, Any], +) -> None: + """Test a start of tiers that are not advertised fails.""" + camera = stream_camera() + await _setup(camera) + assert await _streaming_control(camera, **tiers) == 4 + multi_tier_delegate.handle_start.assert_not_called() + + +async def test_reconfigure( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a start of a started session changes its tiers, not its SSRCs.""" + camera = stream_camera() + await _setup(camera) + await _streaming_control(camera) + session = _session(camera) + ssrcs = (session.video_ssrc, session.audio_ssrc) + + assert await _streaming_control(camera, video_tier=2) == 0 + + multi_tier_delegate.handle_reconfigure.assert_awaited_once_with( + session, MEDIUM, hksv3.DEFAULT_AUDIO_TIER + ) + assert (session.video_ssrc, session.audio_ssrc) == ssrcs + assert session.video_tier == MEDIUM + + +@pytest.mark.parametrize( + ("starts", "hook", "state"), + [ + (1, "handle_start", SessionState.PREPARED), + (2, "handle_reconfigure", SessionState.STARTED), + ], +) +async def test_start_fails( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + starts: int, + hook: str, + state: SessionState, +) -> None: + """Test a failed start or change keeps the session as it was.""" + getattr(multi_tier_delegate, hook).side_effect = StreamError(3) + camera = stream_camera() + await _setup(camera) + statuses = await _starts(camera, starts) + assert statuses[-1] == 3 + assert _session(camera).state is state + + +async def test_start_while_preparing( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a start while the delegate still prepares is busy.""" + prepared = asyncio.Event() + + async def _prepare(session: MultiTierSession) -> None: + await prepared.wait() + + multi_tier_delegate.handle_prepare.side_effect = _prepare + camera = stream_camera() + setup = asyncio.ensure_future(_setup(camera)) + await asyncio.sleep(0) + + assert await _streaming_control(camera) == 3 + + prepared.set() + assert (await setup)[SETUP_TYPES["STATUS"]] == b"\x00" + + +async def test_end( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test an end stops and releases the session once.""" + camera = stream_camera() + await _setup(camera) + await _streaming_control(camera) + session = _session(camera) + + assert await _streaming_control(camera, command=1) == 0 + await camera.stop() + + multi_tier_delegate.handle_end.assert_awaited_once_with(session) + assert session.state is SessionState.ENDED + assert await _streaming_control(camera, command=1) == 1 + + +async def test_end_before_start( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test ending a session that never started still releases it.""" + camera = stream_camera() + await _setup(camera) + assert await _streaming_control(camera, command=1) == 2 + await camera.stop() + multi_tier_delegate.handle_end.assert_awaited_once() + + +async def test_malformed_control(stream_camera: Callable[..., Camera]) -> None: + """Test a write that does not decode answers an error.""" + answer = await _controller(stream_camera())._set_streaming_control("AQ==", CLIENT) + assert base64_to_bytes(answer) == bytes.fromhex("020104") + + +async def test_not_started_in_time( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a session that does not start within setup_timeout ends.""" + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "setup_timeout": 0.01} + ) + await _setup(camera) + await asyncio.sleep(0.05) + await camera.stop() + assert _controller(camera).sessions == {} + multi_tier_delegate.handle_end.assert_awaited_once() + + +async def test_idle_timeout( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a started session ends unless kept alive.""" + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "idle_timeout": 0.01} + ) + await _setup(camera) + await _streaming_control(camera) + await asyncio.sleep(0.1) + assert _controller(camera).sessions == {} + + +async def test_keep_alive(stream_camera: Callable[..., Camera]) -> None: + """Test keeping a started session alive restarts its 30 s idle timer.""" + camera = stream_camera() + await _setup(camera) + await _streaming_control(camera) + session = _session(camera) + timer = session._timer + + session.keep_alive() + + assert timer is not None + assert timer.cancelled() + assert session._timer is not None + assert session._timer.when() > asyncio.get_running_loop().time() + 29 + await camera.stop() + + +async def test_former_session_keeps_nothing_alive( + stream_camera: Callable[..., Camera], +) -> None: + """Test keep_alive() of a former session leaves a new one with its ID alone.""" + camera = stream_camera() + await _setup(camera) + await _streaming_control(camera) + former = _session(camera) + await _setup(camera) + await _streaming_control(camera) + timer = _session(camera)._timer + + former.keep_alive() + + assert timer is not None + assert not timer.cancelled() + assert _session(camera)._timer is timer + await camera.stop() + + +async def _send_rtcp(monitor: RtcpMonitor, host: str) -> None: + """Send a datagram from ``host`` to ``monitor`` and wait until it handled it.""" + handled = asyncio.Event() + received = monitor.datagram_received + + def _received(data: bytes, addr: tuple[Any, ...]) -> None: + received(data, addr) + handled.set() + + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + asyncio.DatagramProtocol, remote_addr=(host, monitor.port) + ) + with patch.object(monitor, "datagram_received", _received): + transport.sendto(b"\x81\xc9\x00\x01") + await asyncio.wait_for(handled.wait(), 2) + transport.close() + + +@pytest.mark.parametrize( + ("address", "kept_alive"), + [(b"127.0.0.1", True), (b"192.168.1.114", False)], + ids=["controller", "other-host"], +) +async def test_rtcp_monitor( + stream_camera: Callable[..., Camera], address: bytes, kept_alive: bool +) -> None: + """Test a datagram from the controller keeps the session alive, others not.""" + camera = stream_camera() + await _setup(camera, _setup_request(address)) + session = _session(camera) + monitor = await open_rtcp_monitor(session) + await _streaming_control(camera) + timer = session._timer + + await _send_rtcp(monitor, "127.0.0.1") + + assert timer is not None + assert timer.cancelled() is kept_alive + assert session.accessory_video_port == monitor.port + assert monitor.port != 0 + monitor.close() + assert monitor.closed + await camera.stop() + + +async def test_rtcp_monitor_ipv6(stream_sensor: hksv3.SensorConfiguration) -> None: + """Test the monitor of an IPv6 controller listens on IPv6.""" + session = MultiTierSession( + id=SESSION, + sensor=stream_sensor, + client_addr=CLIENT, + address="::1", + is_ipv6=True, + video_port=50000, + audio_port=50002, + video_srtp_key=KEY, + video_srtp_salt=SALT, + audio_srtp_key=KEY, + audio_srtp_salt=SALT, + video_ssrc=1, + audio_ssrc=2, + accessory_address="::1", + accessory_video_port=50000, + accessory_audio_port=50002, + ) + monitor = await open_rtcp_monitor(session) + + with patch.object(session, "keep_alive") as keep_alive: + await _send_rtcp(monitor, "::1") + monitor.close() + + keep_alive.assert_called_once_with() + assert session.accessory_video_port == monitor.port + + +@pytest.mark.parametrize( + "end", + [ + lambda camera: camera.set_manually_disabled(True), + lambda camera: camera.client_disconnected(CLIENT), + ], + ids=["streaming-off", "disconnected"], +) +async def test_sessions_end( + stream_camera: Callable[..., Camera], + multi_tier_delegate: AsyncMock, + end: Callable[[Camera], None], +) -> None: + """Test turning streaming off and disconnecting end the sessions.""" + camera = stream_camera() + await _setup(camera) + await _streaming_control(camera) + + end(camera) + await camera.stop() + + assert _controller(camera).sessions == {} + multi_tier_delegate.handle_end.assert_awaited_once() + + +@pytest.mark.parametrize( + ("sockname", "ipv6", "expected"), + [ + (("192.168.1.10", 51826), False, "192.168.1.10"), + (("192.168.1.10", 51826), True, None), + (("::ffff:192.168.1.10", 51826, 0, 0), False, "192.168.1.10"), + (("fe80::10%en0", 51826, 0, 4), True, "fe80::10"), + (None, False, None), + ], +) +async def test_driver_local_address( + stream_driver: Any, sockname: Any, ipv6: bool, expected: str | None +) -> None: + """Test the accessory's address on a connection, in an IP version.""" + connection = MagicMock() + connection.transport.get_extra_info.return_value = sockname + stream_driver.http_server.connections[CLIENT] = connection + assert stream_driver.local_address(CLIENT, ipv6) == expected + assert stream_driver.local_address(OTHER, ipv6) is None From 42d549fc616e6cf0a11a7cf2d3027ee3e0233c69 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:43:02 +0100 Subject: [PATCH 30/56] Stream HKSV 17.99 multi-tier sessions with ffmpeg FFmpegMultiTierDelegate streams each session with its own ffmpeg process. Pass it as the delegate of the multi_tier option, with the input arguments, and audio=False for an input without audio. The command is an argument list, ffmpeg, the input arguments and the outputs, which no shell sees and nothing splits. It copies the video and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with the 48 kHz RTP clock, with the session's SSRCs, keys and the payload types 99 and 110. A start without an audio tier has no audio output. A tier change keeps the process as it is, without adapting the stream: a copied stream cannot change its tier, and a new process with the same SSRC and SRTP key would repeat the keystream. ffmpeg does not report the controller's RTCP. So the delegate opens an RTCP monitor in prepare, announces its port as the video port and lets ffmpeg send video from a free port, with its RTCP to the controller's video port. The controller's RTCP keeps the session alive, without it the session ends after idle_timeout. The session also ends when ffmpeg exits on its own. The SRTP keys are not logged, and URLs in the logged command and in ffmpeg's output keep only their scheme, host and port. --- pyhap/camera_multi_tier.py | 203 ++++++++++++++++++++++++++- tests/conftest.py | 35 ++++- tests/test_camera_multi_tier.py | 238 +++++++++++++++++++++++++++++++- 3 files changed, 473 insertions(+), 3 deletions(-) diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index 00e297cb..269833de 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -14,9 +14,11 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +import contextlib from dataclasses import dataclass, field import logging +import re import secrets from typing import TYPE_CHECKING, Any, Protocol, cast @@ -39,7 +41,10 @@ __all__ = [ "DEFAULT_IDLE_TIMEOUT", + "FFMPEG_AUDIO_OUTPUT", + "FFMPEG_VIDEO_OUTPUT", "MULTI_TIER_MIN_SESSIONS", + "FFmpegMultiTierDelegate", "MultiTierConfig", "MultiTierController", "MultiTierDelegate", @@ -588,3 +593,199 @@ def _answer(session: MultiTierSession) -> hksv3.SetupEndpointsResponse: session.video_ssrc, session.audio_ssrc, ) + + +# Output arguments of FFmpegMultiTierDelegate, each formatted with the +# session values. Video is copied, audio encoded to 16 kHz mono Opus in +# 20 ms frames, which ffmpeg sends with the 48 kHz RTP clock. Video goes out +# from a free port, its announced port is the session's RtcpMonitor. +FFMPEG_VIDEO_OUTPUT = ( + *("-map", "0:v:0", "-an", "-c:v", "copy", "-bsf:v", "dump_extra"), + *("-payload_type", "{v_payload_type}", "-ssrc", "{v_ssrc}", "-f", "rtp"), + *("-srtp_out_suite", "AES_CM_128_HMAC_SHA1_80"), + *("-srtp_out_params", "{v_srtp_key}"), + "srtp://{host}:{v_port}?rtcpport={v_port}&pkt_size=1190", +) +FFMPEG_AUDIO_OUTPUT = ( + *("-map", "0:a:0", "-c:a", "libopus", "-application", "lowdelay"), + *("-frame_duration", "20", "-ar", "16000", "-ac", "1", "-b:a", "{a_bitrate}k"), + *("-payload_type", "{a_payload_type}", "-ssrc", "{a_ssrc}", "-f", "rtp"), + *("-srtp_out_suite", "AES_CM_128_HMAC_SHA1_80"), + *("-srtp_out_params", "{a_srtp_key}"), + "srtp://{host}:{a_port}?rtcpport={a_port}&localrtpport={a_local_port}&pkt_size=188", +) + + +class FFmpegMultiTierDelegate: + """Streams each session with its own ffmpeg process. + + Pass it as the ``delegate`` of the ``multi_tier`` option. The command is + ``ffmpeg``, the ``ffmpeg_input`` arguments, :data:`FFMPEG_VIDEO_OUTPUT` + and, if the controller wants audio, :data:`FFMPEG_AUDIO_OUTPUT`. It is an + argument list that no shell sees. + + The video is copied whatever tier the controller picks. A tier change + keeps the running stream as it is, the delegate does not adapt it to the + tier. + + The audio output maps the first audio stream of the input, so ffmpeg + fails on an input without audio; pass ``audio=False`` for one, and + sessions stream video only. + + ffmpeg does not report the controller's RTCP, so the delegate listens + for it with an :class:`RtcpMonitor` on the announced video port, which + keeps the session alive. When ffmpeg exits on its own, the session ends. + """ + + def __init__( + self, ffmpeg_input: Sequence[str], ffmpeg: str = "ffmpeg", audio: bool = True + ) -> None: + self.argv = (ffmpeg, *ffmpeg_input) + self.audio = audio + + async def handle_prepare(self, session: MultiTierSession) -> None: + """Listen for the controller's RTCP on the announced video port.""" + session.data["rtcp_monitor"] = await open_rtcp_monitor(session) + + def command( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> list[str]: + """The argument list for a session.""" + values = { + "host": f"[{session.address}]" if session.is_ipv6 else session.address, + "v_port": session.video_port, + "a_port": session.audio_port, + "a_local_port": session.accessory_audio_port, + "v_srtp_key": to_base64_str( + session.video_srtp_key + session.video_srtp_salt + ), + "a_srtp_key": to_base64_str( + session.audio_srtp_key + session.audio_srtp_salt + ), + "v_ssrc": session.video_ssrc, + "a_ssrc": session.audio_ssrc, + "v_payload_type": hksv3.VIDEO_PAYLOAD_TYPE, + "a_payload_type": hksv3.AUDIO_PAYLOAD_TYPE, + "a_bitrate": audio_tier.target_bitrate // 1000 if audio_tier else 0, + } + with_audio = self.audio and audio_tier is not None + output = FFMPEG_VIDEO_OUTPUT + (FFMPEG_AUDIO_OUTPUT if with_audio else ()) + return [*self.argv, *(arg.format(**values) for arg in output)] + + async def handle_start( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Start ffmpeg for the session.""" + cmd = self.command(session, video_tier, audio_tier) + logger.debug("[%s] Executing: %s", session.id, _redact_command(cmd)) + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + session.data["process"] = process + session.data["watcher"] = asyncio.ensure_future(_watch(session, process)) + logger.info("[%s] Started stream process %d", session.id, process.pid) + + async def handle_reconfigure( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Keep the running stream. + + A copied stream cannot change its tier, and a new ffmpeg with the + session's SSRC and SRTP key would repeat the keystream. + """ + logger.debug("[%s] Keeping the stream for tier %s", session.id, video_tier) + + async def handle_end(self, session: MultiTierSession) -> None: + """Stop the session's ffmpeg, killing it after two seconds.""" + monitor: RtcpMonitor | None = session.data.pop("rtcp_monitor", None) + if monitor is not None: + monitor.close() + process = session.data.pop("process", None) + if process is None: + return + logger.info("[%s] Stopping the stream process", session.id) + with contextlib.suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), 2.0) + except asyncio.TimeoutError: + logger.warning("[%s] Killing the stream process", session.id) + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.wait() + await session.data.pop("watcher") + + +def _redact_command(cmd: list[str]) -> str: + """Join the command for logging, without the SRTP keys and input secrets.""" + return " ".join( + "" if previous == "-srtp_out_params" else _redact_arg(arg) + for previous, arg in zip(["", *cmd], cmd, strict=False) + ) + + +# User info runs up to the last "@" of the token, it may hold any character. +_URL = re.compile( + r"(?P[a-z][a-z0-9+.-]*://)(?:\S*@)?(?P[^\s'\"]*)", re.IGNORECASE +) +# An argument that is a URL is one token, its user info may hold spaces. +_URL_ARG = re.compile( + r"(?P[a-z][a-z0-9+.-]*://)(?:.*@)?(?P.*)", re.IGNORECASE | re.DOTALL +) + + +def _redact_url(match: re.Match[str]) -> str: + # Keep the host and port, drop user info, path and query. + host, *path = re.split(r"[/?#]", match["rest"], maxsplit=1) + return f"{match['scheme']}{host}{'/' if path else ''}" + + +def _redact_arg(arg: str) -> str: + """Redact a command argument, a URL argument as a whole.""" + if match := _URL_ARG.fullmatch(arg): + return _redact_url(match) + return _redact_urls(arg) + + +def _redact_urls(text: str) -> str: + """Drop user info, path and query of each URL in ``text``, e.g. for logs. + + Camera URLs often carry credentials or access tokens there. + """ + return _URL.sub(_redact_url, text) + + +async def _watch( + session: MultiTierSession, process: asyncio.subprocess.Process +) -> None: + """Log ffmpeg's output and end the session with ffmpeg.""" + output = asyncio.ensure_future(_log_output(session, process)) + code = await process.wait() + await output + if session.data.get("process") is process: + logger.warning("[%s] Stream process exited with %s", session.id, code) + session.end("the stream process exited") + + +async def _log_output( + session: MultiTierSession, process: asyncio.subprocess.Process +) -> None: + # Reading the output also keeps the pipe from filling up. + if process.stderr is None: + return + async for line in process.stderr: + # Token based, user info with a raw space is not covered. + text = _redact_urls(line.decode(errors="replace").rstrip()) + logger.debug("[%s] %s", session.id, text) diff --git a/tests/conftest.py b/tests/conftest.py index 5f041320..9ae1235b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Iterator import pathlib -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 @@ -196,3 +196,36 @@ def _make(**options: Any) -> Camera: ) return _make + + +class _FFmpegProcess: + """An ffmpeg process that runs until it is terminated or exits.""" + + def __init__(self, *lines: bytes) -> None: + self.pid = 42 + self.returncode: int | None = None + self._exited = asyncio.Event() + self.stderr = self._stderr(lines) + self.terminate = MagicMock(side_effect=lambda: self.exit(-15)) + self.kill = MagicMock(side_effect=lambda: self.exit(-9)) + + async def _stderr(self, lines: tuple[bytes, ...]) -> AsyncIterator[bytes]: + for line in lines: + yield line + await self._exited.wait() + + def exit(self, code: int) -> None: + self.returncode = code + self._exited.set() + + async def wait(self) -> int: + await self._exited.wait() + return cast(int, self.returncode) + + +@pytest.fixture +def ffmpeg_process() -> _FFmpegProcess: + """A running ffmpeg process, its output has a frame and an input line.""" + return _FFmpegProcess( + b"frame=1\n", b"Input #0, rtsp, from 'rtsp://user:secret@cam/stream-token':\n" + ) diff --git a/tests/test_camera_multi_tier.py b/tests/test_camera_multi_tier.py index 8529c360..d6a59aab 100644 --- a/tests/test_camera_multi_tier.py +++ b/tests/test_camera_multi_tier.py @@ -2,9 +2,10 @@ import asyncio from collections.abc import Callable +import logging import struct from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch from uuid import UUID import pytest @@ -12,9 +13,12 @@ from pyhap import hksv3, tlv from pyhap.camera import SETUP_ADDR_INFO, SETUP_SRTP_PARAM, SETUP_TYPES, Camera from pyhap.camera_multi_tier import ( + FFmpegMultiTierDelegate, MultiTierController, MultiTierSession, RtcpMonitor, + _redact_command, + _redact_urls, open_rtcp_monitor, ) from pyhap.camera_stream import SessionState, StreamError @@ -951,3 +955,235 @@ async def test_driver_local_address( stream_driver.http_server.connections[CLIENT] = connection assert stream_driver.local_address(CLIENT, ipv6) == expected assert stream_driver.local_address(OTHER, ipv6) is None + + +# The ffmpeg delegate + +FFMPEG_INPUT = ["-i", "rtsp://user:secret@cam/stream-token"] + + +async def _ffmpeg_session( + stream_camera: Callable[..., Camera], request: str | None = None, **options: Any +) -> tuple[Camera, MultiTierSession, FFmpegMultiTierDelegate]: + delegate = FFmpegMultiTierDelegate(FFMPEG_INPUT) + camera = stream_camera(multi_tier={"delegate": delegate, **options}) + await _setup(camera, request) + return camera, _session(camera), delegate + + +async def test_ffmpeg_command(stream_camera: Callable[..., Camera]) -> None: + """Test the command is an argument list with video and audio outputs.""" + camera, session, delegate = await _ffmpeg_session(stream_camera) + + cmd = delegate.command(session, HIGH, hksv3.DEFAULT_AUDIO_TIER) + await camera.stop() + + assert cmd[:3] == ["ffmpeg", *FFMPEG_INPUT] + assert cmd[cmd.index("-ssrc") + 1] == str(session.video_ssrc) + assert cmd[cmd.index("-srtp_out_params") + 1] == to_base64_str(KEY + SALT) + assert cmd[cmd.index("-payload_type") + 1] == "99" + assert cmd[cmd.index("-b:a") + 1] == "24k" + assert cmd[cmd.index("libopus") - 3 : cmd.index("libopus")] == [ + "-map", + "0:a:0", + "-c:a", + ] + assert cmd[-1] == ( + "srtp://192.168.1.114:50002?rtcpport=50002&localrtpport=50002&pkt_size=188" + ) + assert "srtp://192.168.1.114:50000?rtcpport=50000&pkt_size=1190" in cmd + + +async def test_ffmpeg_command_video_only( + stream_camera: Callable[..., Camera], +) -> None: + """Test a stream without audio has no audio output.""" + camera, session, delegate = await _ffmpeg_session(stream_camera) + cmd = delegate.command(session, HIGH, None) + await camera.stop() + assert "libopus" not in cmd + assert cmd[-1].startswith("srtp://192.168.1.114:50000?") + + +async def test_ffmpeg_command_input_without_audio( + stream_camera: Callable[..., Camera], +) -> None: + """Test audio=False streams video only even when the controller wants audio.""" + camera, session, delegate = await _ffmpeg_session(stream_camera) + video_only = FFmpegMultiTierDelegate(FFMPEG_INPUT, audio=False) + + cmd = video_only.command(session, HIGH, hksv3.DEFAULT_AUDIO_TIER) + await camera.stop() + + assert cmd == delegate.command(session, HIGH, None) + assert "0:a:0" not in cmd + + +async def test_ffmpeg_command_ipv6(stream_camera: Callable[..., Camera]) -> None: + """Test an IPv6 controller is bracketed in the URLs.""" + with patch( + "pyhap.accessory_driver.AccessoryDriver.local_address", return_value="fd00::10" + ): + camera, session, delegate = await _ffmpeg_session( + stream_camera, _setup_request(b"fd00::1", True) + ) + await camera.stop() + assert delegate.command(session, HIGH, None)[-1].startswith("srtp://[fd00::1]:") + + +async def test_ffmpeg_start_and_stop( + stream_camera: Callable[..., Camera], + ffmpeg_process: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ffmpeg starts once, keeps running on a tier change and stops.""" + camera, session, delegate = await _ffmpeg_session(stream_camera) + expected = delegate.command(session, HIGH, hksv3.DEFAULT_AUDIO_TIER) + with ( + patch("asyncio.create_subprocess_exec", return_value=ffmpeg_process) as start, + caplog.at_level(logging.DEBUG, logger="pyhap.camera_multi_tier"), + ): + assert await _streaming_control(camera) == 0 + assert await _streaming_control(camera, video_tier=2) == 0 + assert await _streaming_control(camera, command=1) == 0 + + start.assert_called_once_with(*expected, stdin=ANY, stdout=ANY, stderr=ANY) + ffmpeg_process.terminate.assert_called_once() + assert "frame=1" in caplog.text + assert "from 'rtsp://cam/'" in caplog.text + assert to_base64_str(KEY + SALT) not in caplog.text + assert "secret" not in caplog.text + assert "stream-token" not in caplog.text + assert "exited" not in caplog.text + + +async def test_ffmpeg_exit_ends_the_session( + stream_camera: Callable[..., Camera], + ffmpeg_process: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the session ends when ffmpeg exits on its own.""" + camera, _, _ = await _ffmpeg_session(stream_camera) + with patch("asyncio.create_subprocess_exec", return_value=ffmpeg_process): + assert await _streaming_control(camera) == 0 + + ffmpeg_process.exit(1) + await asyncio.sleep(0.01) + + assert _controller(camera).sessions == {} + assert "Stream process exited with 1" in caplog.text + await camera.stop() + + +async def test_ffmpeg_announces_the_rtcp_monitor( + stream_camera: Callable[..., Camera], +) -> None: + """Test the answer announces the port the RTCP monitor bound.""" + camera = stream_camera( + multi_tier={"delegate": FFmpegMultiTierDelegate(FFMPEG_INPUT)} + ) + + answer = await _setup(camera) + + monitor = _session(camera).data["rtcp_monitor"] + assert tlv.decode(answer[SETUP_TYPES["ADDRESS"]])[ + SETUP_ADDR_INFO["VIDEO_RTP_PORT"] + ] == struct.pack(" None: + """Test a running ffmpeg does not keep a session alive without RTCP.""" + camera, session, _ = await _ffmpeg_session(stream_camera, idle_timeout=0.05) + monitor = session.data["rtcp_monitor"] + with patch("asyncio.create_subprocess_exec", return_value=ffmpeg_process): + assert await _streaming_control(camera) == 0 + await asyncio.sleep(0.2) + + assert session.ended + assert _controller(camera).sessions == {} + ffmpeg_process.terminate.assert_called_once() + assert monitor.closed + await camera.stop() + + +async def test_ffmpeg_rtcp_keeps_the_session_alive( + stream_camera: Callable[..., Camera], ffmpeg_process: Any +) -> None: + """Test the controller's RTCP keeps a running ffmpeg session alive.""" + camera, session, _ = await _ffmpeg_session( + stream_camera, _setup_request(b"127.0.0.1") + ) + with patch("asyncio.create_subprocess_exec", return_value=ffmpeg_process): + assert await _streaming_control(camera) == 0 + timer = session._timer + + await _send_rtcp(session.data["rtcp_monitor"], "127.0.0.1") + + assert timer is not None + assert timer.cancelled() + assert session.state is SessionState.STARTED + await camera.stop() + + +async def test_ffmpeg_start_fails(stream_camera: Callable[..., Camera]) -> None: + """Test a missing ffmpeg answers an error.""" + camera, _, _ = await _ffmpeg_session(stream_camera) + with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError): + assert await _streaming_control(camera) == 4 + await camera.stop() + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("rtsps://cam:7441/token?enableSrtp", "rtsps://cam:7441/"), + ( + "from 'rtsp://u:p@[fd00::1]:554/s':", + "from 'rtsp://[fd00::1]:554/':", + ), + ("rtsp://u:p@cam", "rtsp://cam"), + ("rtsp://admin:abc/def@cam/s", "rtsp://cam/"), + ("rtsp://admin:p#w@cam/s", "rtsp://cam/"), + ("from 'rtsp://admin:pa'ss@cam/s'", "from 'rtsp://cam/'"), + ("srtp://192.168.1.114:50000", "srtp://192.168.1.114:50000"), + ("frame=1 fps=30", "frame=1 fps=30"), + ], +) +def test_redact_urls(text: str, expected: str) -> None: + """Test URLs keep only their scheme, host and port.""" + assert _redact_urls(text) == expected + + +@pytest.mark.parametrize( + ("cmd", "expected"), + [ + ( + ["ffmpeg", "-i", "rtsp://admin:my pass@cam/s"], + "ffmpeg -i rtsp://cam/", + ), + ( + ["ffmpeg", "-i", "rtsp://admin:my\npass@cam/s"], + "ffmpeg -i rtsp://cam/", + ), + ( + ["ffmpeg", "-i", "rtsps://u:p@w@cam:7441/s?token"], + "ffmpeg -i rtsps://cam:7441/", + ), + ( + ["ffmpeg", "-srtp_out_params", "secret", "srtp://192.168.1.114:50000"], + "ffmpeg -srtp_out_params srtp://192.168.1.114:50000", + ), + ( + ["ffmpeg", "-f", "tee", "[f=rtsp]rtsp://u:p@cam/s"], + "ffmpeg -f tee [f=rtsp]rtsp://cam/", + ), + ], +) +def test_redact_command(cmd: list[str], expected: str) -> None: + """Test each URL argument of a command is redacted as a whole.""" + assert _redact_command(cmd) == expected From d2190a9cc123050470e1893214fef83deb1ec4e2 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:43:29 +0100 Subject: [PATCH 31/56] Solicit, answer and end HKSV 17.99 WebRTC sessions A controller views remotely through the Camera WebRTC Stream Management service: it solicits an offer, provides its answer and ends the session with WebRTC Streaming Control (guide chapter 5). - Solicit Offer: no offer while the camera is off (privacy mode), while streaming is not allowed, without a delegate, beyond max_sessions or beyond max_sessions_per_connection for the writing connection. The delegate gets the session and the tiers the offer advertises. SFrame is used unless the controller turned it off explicitly. The Scrypted HomeKit plugin found remote viewing needs it when the option is left out. The offer must carry an SFrame key exactly then. The SDP carries the ICE candidates, the answer lists none. - Provide Answer: only from the soliciting connection and only once. A second answer while the first is applied is busy, one after it an error. An answer above the SDP or candidate limit does not decode and answers an error before the delegate sees it. Afterwards the session counts in Number of Active Sessions. - A session without an applied answer ends after answer_timeout (30 s). The timer stops only once the answer applied and checks the state again when it fires. - A failed or slow offer or answer ends the session. The delegate's handle_end() releases every session it saw exactly once, in the background, so a timeout never waits for it inside the HAP response time. The answers are write responses. A test through the driver checks that only the writing connection reads them back. --- pyhap/camera_webrtc.py | 178 +++++++++++- tests/test_camera_webrtc.py | 478 +++++++++++++++++++++++++++++++- tests/test_camera_webrtc_hap.py | 115 ++++++++ 3 files changed, 766 insertions(+), 5 deletions(-) create mode 100644 tests/test_camera_webrtc_hap.py diff --git a/pyhap/camera_webrtc.py b/pyhap/camera_webrtc.py index 5ae66969..4f0a7648 100644 --- a/pyhap/camera_webrtc.py +++ b/pyhap/camera_webrtc.py @@ -15,19 +15,24 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast +from uuid import UUID, uuid4 from pyhap import hksv3 from pyhap.camera_stream import ( DEFAULT_HOOK_TIMEOUT, + HookFailed, + SessionState, StreamController, StreamSession, check_hook_timeout, config_from_option, + failure_status, ) +from pyhap.util import base64_to_bytes, to_base64_str if TYPE_CHECKING: from pyhap.camera import Camera @@ -46,6 +51,9 @@ # WebRTC sessions per sensor the guide requires at least. WEBRTC_MIN_SESSIONS = 6 +_R = TypeVar("_R") +_STATUS = hksv3.WebRTCStreamingStatus + @dataclass(eq=False, kw_only=True) class WebRTCSession(StreamSession): @@ -125,7 +133,8 @@ class WebRTCConfig: ``max_sessions`` per sensor is at least six, as the guide requires. ``max_sessions_per_connection`` limits one connection, by default to ``max_sessions``, since remote viewers share the home hub's connection. A - delegate hook gets ``hook_timeout`` seconds. The offer + delegate hook gets ``hook_timeout`` seconds. A session the controller + did not answer within ``answer_timeout`` seconds ends. The offer advertises the sensor's video tier of ``remote_quality``, the lowest tier if it has none. Medium keeps 4K off the remote path; the Scrypted HomeKit plugin offers the first tier. @@ -135,6 +144,7 @@ class WebRTCConfig: max_sessions: int = WEBRTC_MIN_SESSIONS max_sessions_per_connection: int | None = None hook_timeout: float = DEFAULT_HOOK_TIMEOUT + answer_timeout: float = 30.0 remote_quality: hksv3.VideoQuality | int = hksv3.VideoQuality.MEDIUM @classmethod @@ -156,6 +166,8 @@ def __post_init__(self) -> None: raise ValueError( "webrtc max_sessions_per_connection must be 1 to max_sessions" ) + if self.answer_timeout <= 0: + raise ValueError("webrtc answer_timeout must be positive") if self.remote_quality not in set(hksv3.VideoQuality): raise ValueError(f"Unknown webrtc remote_quality {self.remote_quality}") @@ -168,6 +180,20 @@ def remote_tier( return min(matching, key=lambda tier: (tier.width * tier.height, tier.identifier)) +def _decode(name: str, decode: Callable[[bytes], _R], value: str) -> _R | None: + try: + request = decode(base64_to_bytes(value)) + except ValueError as ex: + logger.warning("Ignoring a malformed WebRTC %s write: %s", name, ex) + return None + logger.debug("WebRTC %s for %s", name, getattr(request, "session_id", None)) + return request + + +def _status(session_id: UUID | None, status: hksv3.WebRTCStreamingStatus) -> str: + return to_base64_str(hksv3.WebRTCSessionStatus(session_id, status).encode()) + + class WebRTCController(StreamController[WebRTCSession]): """The Camera WebRTC Stream Management service of one sensor.""" @@ -207,6 +233,13 @@ def __init__( value = multi_tier.service.get_characteristic(source).value service.configure_char(name, value=value) self.streaming_enabled = service.configure_char("StreamingEnabled", value=True) + self._session_count = service.get_characteristic("WebRTCNumberOfActiveSessions") + for name, setter in ( + ("WebRTCSolicitOffer", self._set_solicit_offer), + ("WebRTCProvideAnswer", self._set_provide_answer), + ("WebRTCStreamingControl", self._set_streaming_control), + ): + service.configure_char(name, setter_callback=setter) def refusal(self) -> hksv3.WebRTCSolicitOfferStatus | None: """Why the sensor may not stream now, None if it may.""" @@ -221,6 +254,17 @@ def update_status(self) -> None: if self.refusal() is not None: self._end_sessions("streaming was turned off") + def _session_ended(self, session: WebRTCSession, previous: SessionState) -> None: + if previous is SessionState.ACTIVE: + self._update_session_count() + + def _update_session_count(self) -> None: + """Set and notify Number of Active Sessions, answered sessions count.""" + count = sum( + session.state is SessionState.ACTIVE for session in self._sessions.values() + ) + self._session_count.set_value(min(count, 255)) + @property def _delegate(self) -> WebRTCDelegate: # Only a controller with a delegate has sessions. @@ -228,3 +272,131 @@ def _delegate(self) -> WebRTCDelegate: async def _release_hook(self, session: WebRTCSession) -> None: await self._delegate.handle_end(session) + + async def _set_solicit_offer( + self, value: str, sender_client_addr: Any = None + ) -> str: + deadline = self._deadline() + request = _decode("Solicit Offer", hksv3.WebRTCSolicitOffer.decode, value) + if request is None: + response = hksv3.WebRTCSolicitOfferResponse( + hksv3.WebRTCSolicitOfferStatus.ERROR + ) + else: + response = await self._solicit_offer( + request.options, sender_client_addr, deadline + ) + logger.debug( + "[%s] WebRTC Solicit Offer: %s", response.session_id, response.status + ) + return to_base64_str(response.encode()) + + async def _solicit_offer( + self, options: hksv3.WebRTCOfferOptions, client_addr: Any, deadline: float + ) -> hksv3.WebRTCSolicitOfferResponse: + error = hksv3.WebRTCSolicitOfferStatus.ERROR + refusal = self.refusal() + if refusal is not None: + logger.info("Refusing a WebRTC offer, streaming is off: %s", refusal) + return hksv3.WebRTCSolicitOfferResponse(refusal) + # The guide has no Busy for an offer. + if no_room := self._no_room(client_addr): + logger.warning("%s, refusing an offer", no_room) + return hksv3.WebRTCSolicitOfferResponse(error) + if self.delegate is None: + logger.warning("No WebRTC delegate, refusing an offer") + return hksv3.WebRTCSolicitOfferResponse(error) + session = WebRTCSession( + id=uuid4(), + sensor=self.sensor, + client_addr=client_addr, + sframe=options.sframe_enabled is not False, + video_tier=self.video_tier, + audio_tier=self.audio_tier, + ) + self._add(session) + self._set_timer( + session, + self.config.answer_timeout, + "No WebRTC answer in time", + SessionState.NEW, + SessionState.SOLICITED, + SessionState.ANSWERING, + ) + hook = self.delegate.handle_solicit_offer( + session, session.video_tier, session.audio_tier + ) + try: + offer = await self._run_hook( + session, + "solicit offer", + hook, + deadline, + end_on_failure=True, + first=True, + ) + except HookFailed as err: + return hksv3.WebRTCSolicitOfferResponse( + failure_status(hksv3.WebRTCSolicitOfferStatus, err.status, error) + ) + if (offer.sframe is not None) != session.sframe: + logger.error("[%s] The offer's SFrame key does not fit", session.id) + self._end(session, "the offer's SFrame key does not fit") + return hksv3.WebRTCSolicitOfferResponse(error) + session.state = SessionState.SOLICITED + return hksv3.WebRTCSolicitOfferResponse( + hksv3.WebRTCSolicitOfferStatus.SUCCESS, + session.id, + offer.sdp, + [], + offer.sframe, + ) + + async def _set_provide_answer( + self, value: str, sender_client_addr: Any = None + ) -> str: + deadline = self._deadline() + request = _decode("Provide Answer", hksv3.WebRTCProvideAnswer.decode, value) + if request is None: + return _status(None, _STATUS.ERROR) + session = self._owned(request.session_id, sender_client_addr) + if session is None: + return _status(request.session_id, _STATUS.UNKNOWN_SESSION) + if session.state is SessionState.ANSWERING: + # Another answer is being applied. + return _status(session.id, _STATUS.BUSY) + if session.state is not SessionState.SOLICITED: + # Answered already. + return _status(session.id, _STATUS.ERROR) + session.state = SessionState.ANSWERING + hook = self._delegate.handle_answer( + session, request.sdp_answer, request.candidates + ) + try: + await self._run_hook(session, "answer", hook, deadline, end_on_failure=True) + except HookFailed as err: + return _status( + session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + ) + # Only now, the expiry ends a session whose answer did not apply. + self._cancel_timer(session) + session.state = SessionState.ACTIVE + self._update_session_count() + return _status(session.id, _STATUS.SUCCESS) + + async def _set_streaming_control( + self, value: str, sender_client_addr: Any = None + ) -> str: + deadline = self._deadline() + request = _decode( + "Streaming Control", hksv3.WebRTCStreamingControl.decode, value + ) + if request is None: + return _status(None, _STATUS.ERROR) + session = self._owned(request.session_id, sender_client_addr) + if session is None: + return _status(request.session_id, _STATUS.UNKNOWN_SESSION) + if request.command != hksv3.WebRTCStreamingCommand.END: + return _status(session.id, _STATUS.ERROR) + ended = await self._end_by_request(session, deadline) + return _status(session.id, _STATUS.SUCCESS if ended else _STATUS.ERROR) diff --git a/tests/test_camera_webrtc.py b/tests/test_camera_webrtc.py index 3659a02d..c6a074ec 100644 --- a/tests/test_camera_webrtc.py +++ b/tests/test_camera_webrtc.py @@ -1,9 +1,18 @@ """Tests for the HKSV 17.99 WebRTC streaming of pyhap.camera.""" -from uuid import UUID +import asyncio +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest from pyhap import hksv3 -from pyhap.camera_webrtc import remote_tier +from pyhap.camera import Camera +from pyhap.camera_stream import SessionState, StreamError +from pyhap.camera_webrtc import WebRTCController, WebRTCSession, remote_tier +from pyhap.util import base64_to_bytes, to_base64_str WEBRTC = "CameraWebRTCStreamManagement" SENSOR = UUID("00112233-4455-6677-8899-aabbccddeeff") @@ -15,8 +24,473 @@ KEY = hksv3.SFrameKey(bytes(32), 7) +def _controller(camera: Camera) -> WebRTCController: + return camera.webrtc_controllers[SENSOR] + + +def _count(camera: Camera) -> int: + service = camera.get_service(WEBRTC) + assert service is not None + return int(service.get_characteristic("WebRTCNumberOfActiveSessions").value) + + +async def _solicit( + camera: Camera, sframe: bool | None = True, client: Any = CLIENT +) -> hksv3.WebRTCSolicitOfferResponse: + request = hksv3.WebRTCSolicitOffer(hksv3.WebRTCOfferOptions(sframe)) + response = await _controller(camera)._set_solicit_offer( + to_base64_str(request.encode()), client + ) + return hksv3.WebRTCSolicitOfferResponse.decode(base64_to_bytes(response)) + + +async def _session(camera: Camera) -> WebRTCSession: + """Solicit an offer and return the new session.""" + response = await _solicit(camera) + assert response.session_id is not None + return _controller(camera).sessions[response.session_id] + + +def _status(response: str) -> hksv3.WebRTCSessionStatus: + return hksv3.WebRTCSessionStatus.decode(base64_to_bytes(response)) + + +async def _answer( + camera: Camera, session_id: UUID, client: Any = CLIENT +) -> hksv3.WebRTCSessionStatus: + request = hksv3.WebRTCProvideAnswer( + session_id, + ANSWER, + [hksv3.IceCandidate("candidate:1 1 udp 1 1.2.3.4 5 typ host")], + ) + return _status( + await _controller(camera)._set_provide_answer( + to_base64_str(request.encode()), client + ) + ) + + +async def _end( + camera: Camera, session_id: UUID, client: Any = CLIENT, command: int = 1 +) -> hksv3.WebRTCSessionStatus: + request = hksv3.WebRTCStreamingControl(session_id, command) + return _status( + await _controller(camera)._set_streaming_control( + to_base64_str(request.encode()), client + ) + ) + + +async def _active(camera: Camera) -> WebRTCSession: + session = await _session(camera) + assert (await _answer(camera, session.id)).status == 0 + return session + + +async def _solicit_many(camera: Camera, count: int) -> list[int]: + return [(await _solicit(camera)).status for _ in range(count)] + + +# Sessions + + +async def _hang(*_args: object) -> None: + """Never return, as a hook stuck beyond its timeout.""" + await asyncio.Event().wait() + + def test_remote_tier(stream_sensor: hksv3.SensorConfiguration) -> None: """Test the remote tier is the smallest of a quality, else the smallest.""" tiers = hksv3.tiers_from_sensor(stream_sensor) assert remote_tier(tiers, hksv3.VideoQuality.MEDIUM) == MEDIUM assert remote_tier(tiers, hksv3.VideoQuality.HIGHEST) == LOW + + +async def test_solicit_offer( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an offer of the remote tier with its SFrame key, ICE in the SDP.""" + camera = stream_camera() + + response = await _solicit(camera, sframe=None) + + assert response.status == hksv3.WebRTCSolicitOfferStatus.SUCCESS + assert response.sdp_offer == "v=0\r\n" + assert response.candidates == [] + assert response.sframe == KEY + assert response.session_id is not None + session = _controller(camera).sessions[response.session_id] + assert session.state is SessionState.SOLICITED + assert session.sframe is True + assert session.client_addr == CLIENT + webrtc_delegate.handle_solicit_offer.assert_awaited_once_with( + session, MEDIUM, hksv3.DEFAULT_AUDIO_TIER + ) + assert _count(camera) == 0 + + +async def test_solicit_offer_without_sframe( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a controller that turns SFrame off gets an offer without a key.""" + webrtc_delegate.handle_solicit_offer.return_value = hksv3.WebRTCOffer("v=0\r\n") + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "remote_quality": 4}) + + response = await _solicit(camera, sframe=False) + + assert response.status == 0 + assert response.sframe is None + assert response.session_id is not None + webrtc_delegate.handle_solicit_offer.assert_awaited_once_with( + _controller(camera).sessions[response.session_id], + LOW, + hksv3.DEFAULT_AUDIO_TIER, + ) + + +@pytest.mark.parametrize(("sframe", "key"), [(True, None), (False, KEY)]) +async def test_offer_sframe_must_fit( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + sframe: bool, + key: hksv3.SFrameKey | None, +) -> None: + """Test an offer with a key the session does not want, or without one it needs.""" + webrtc_delegate.handle_solicit_offer.return_value = hksv3.WebRTCOffer( + "v=0\r\n", key + ) + camera = stream_camera() + + response = await _solicit(camera, sframe=sframe) + await camera.stop() + + assert response.status == 2 + assert _controller(camera).sessions == {} + webrtc_delegate.handle_end.assert_awaited_once() + + +@pytest.mark.parametrize( + ("change", "status"), + [ + (lambda camera: camera.set_manually_disabled(True), 2), + ( + lambda camera: ( + camera.get_service("CameraGlobalOperatingMode") + .get_characteristic("HomeKitCameraActive") + .set_value(False) + ), + 1, + ), + ], + ids=["streaming-off", "privacy-mode"], +) +async def test_solicit_offer_refused( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + change: Callable[[Camera], None], + status: int, +) -> None: + """Test no offer while streaming is off or the camera is off.""" + camera = stream_camera() + change(camera) + assert (await _solicit(camera)).status == status + webrtc_delegate.handle_solicit_offer.assert_not_called() + + +async def test_solicit_offer_without_delegate( + stream_camera: Callable[..., Camera], +) -> None: + """Test a camera without a WebRTC delegate refuses offers.""" + assert (await _solicit(stream_camera(webrtc={}))).status == 2 + + +async def test_solicit_offer_limit(stream_camera: Callable[..., Camera]) -> None: + """Test one connection may solicit max_sessions, beyond fails without busy.""" + camera = stream_camera() + assert await _solicit_many(camera, 7) == [0, 0, 0, 0, 0, 0, 2] + + +async def test_solicit_offer_limit_per_connection( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a connection limit of three, another connection still gets its own.""" + camera = stream_camera( + webrtc={"delegate": webrtc_delegate, "max_sessions_per_connection": 3} + ) + + assert await _solicit_many(camera, 4) == [0, 0, 0, 2] + assert (await _solicit(camera, client=OTHER)).status == 0 + + +@pytest.mark.parametrize( + ("error", "status"), + [(RuntimeError(), 2), (StreamError(1), 1), (NotImplementedError(), 2)], +) +async def test_solicit_offer_fails( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + error: Exception, + status: int, +) -> None: + """Test a failed offer answers its status and releases the session once.""" + webrtc_delegate.handle_solicit_offer.side_effect = error + camera = stream_camera() + + response = await _solicit(camera) + await camera.stop() + + assert response.status == status + assert response.session_id is None + webrtc_delegate.handle_end.assert_awaited_once() + + +async def test_slow_offer( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an offer beyond hook_timeout answers an error, the end runs after.""" + webrtc_delegate.handle_solicit_offer.side_effect = _hang + webrtc_delegate.handle_end.side_effect = _hang + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "hook_timeout": 0.05}) + + response = await _solicit(camera) + + assert response.status == 2 + assert "The solicit offer hook timed out" in caplog.text + webrtc_delegate.handle_end.assert_not_awaited() + await asyncio.sleep(0.01) + webrtc_delegate.handle_end.assert_awaited_once() + await camera.stop() + + +async def test_answer( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an answer makes the session active and counts it.""" + camera = stream_camera() + session = await _session(camera) + + status = await _answer(camera, session.id) + + assert status == hksv3.WebRTCSessionStatus(session.id, 0) + assert session.state is SessionState.ACTIVE + assert _count(camera) == 1 + webrtc_delegate.handle_answer.assert_awaited_once_with( + session, + ANSWER, + [hksv3.IceCandidate("candidate:1 1 udp 1 1.2.3.4 5 typ host")], + ) + assert (await _answer(camera, session.id)).status == 3 + + +async def test_answer_of_another_controller( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test only the soliciting connection answers, others see no session.""" + camera = stream_camera() + session = await _session(camera) + + assert (await _answer(camera, session.id, OTHER)).status == 1 + assert (await _answer(camera, uuid4())).status == 1 + assert (await _end(camera, session.id, OTHER)).status == 1 + webrtc_delegate.handle_answer.assert_not_called() + assert session.state is SessionState.SOLICITED + + +async def test_concurrent_answer( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an answer while one is applied answers busy.""" + applied = asyncio.Event() + + async def _apply(*_: Any) -> None: + await applied.wait() + + webrtc_delegate.handle_answer.side_effect = _apply + camera = stream_camera() + session = await _session(camera) + first = asyncio.ensure_future(_answer(camera, session.id)) + await asyncio.sleep(0) + + assert session.state is SessionState.ANSWERING + assert (await _answer(camera, session.id)).status == 2 + + applied.set() + assert (await first).status == 0 + webrtc_delegate.handle_answer.assert_awaited_once() + + +async def test_answer_fails( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a failed answer ends the session and releases it once.""" + webrtc_delegate.handle_answer.side_effect = RuntimeError + camera = stream_camera() + session = await _session(camera) + + assert (await _answer(camera, session.id)).status == 3 + await camera.stop() + + assert session.state is SessionState.ENDED + assert _count(camera) == 0 + webrtc_delegate.handle_end.assert_awaited_once_with(session) + + +async def test_no_answer_in_time( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a session the controller does not answer in time ends.""" + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "answer_timeout": 0.01}) + session = await _session(camera) + await asyncio.sleep(0.05) + await camera.stop() + assert session.state is SessionState.ENDED + webrtc_delegate.handle_end.assert_awaited_once_with(session) + + +async def test_answer_ends_the_expiry( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an answered session outlives the answer timeout.""" + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "answer_timeout": 0.05}) + session = await _active(camera) + await asyncio.sleep(0.1) + assert session.state is SessionState.ACTIVE + + +async def test_expiry_while_answering( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiry still ends a session whose answer is not applied yet.""" + + async def _apply(*_: Any) -> None: + # Only the expiry ends it, long before the hook times out. + await asyncio.Event().wait() + + webrtc_delegate.handle_answer.side_effect = _apply + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "answer_timeout": 0.05}) + session = await _session(camera) + + assert (await _answer(camera, session.id)).status == 3 + await camera.stop() + + assert "No WebRTC answer in time" in caplog.text + assert session.state is SessionState.ENDED + webrtc_delegate.handle_end.assert_awaited_once_with(session) + + +async def test_end( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test the controller ends a session, the count goes down.""" + camera = stream_camera() + session = await _active(camera) + + assert (await _end(camera, session.id)).status == 0 + + assert _count(camera) == 0 + webrtc_delegate.handle_end.assert_awaited_once_with(session) + assert (await _end(camera, session.id)).status == 1 + + +async def test_end_fails( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a failed release answers an error, the session is gone anyway.""" + webrtc_delegate.handle_end.side_effect = RuntimeError + camera = stream_camera() + session = await _active(camera) + assert (await _end(camera, session.id)).status == 3 + assert _controller(camera).sessions == {} + + +async def test_unknown_command(stream_camera: Callable[..., Camera]) -> None: + """Test a command other than end fails.""" + camera = stream_camera() + session = await _active(camera) + assert (await _end(camera, session.id, command=2)).status == 3 + assert session.state is SessionState.ACTIVE + + +@pytest.mark.parametrize( + "end", + [ + lambda camera: camera.set_manually_disabled(True), + lambda camera: camera.client_disconnected(CLIENT), + ], + ids=["streaming-off", "disconnected"], +) +async def test_sessions_end( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + end: Callable[[Camera], None], +) -> None: + """Test turning streaming off and disconnecting end the sessions.""" + camera = stream_camera() + session = await _active(camera) + + end(camera) + await camera.stop() + + assert session.state is SessionState.ENDED + assert _count(camera) == 0 + webrtc_delegate.handle_end.assert_awaited_once_with(session) + + +async def test_camera_ends_a_session( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test the integrator ends a session and all of them.""" + camera = stream_camera() + first = await _active(camera) + second = await _session(camera) + + assert await camera.end_session(first.id) is True + assert await camera.end_session(first.id) is False + await camera.end_all_sessions() + + assert second.state is SessionState.ENDED + assert webrtc_delegate.handle_end.await_count == 2 + + +async def test_malformed_writes(stream_camera: Callable[..., Camera]) -> None: + """Test writes that do not decode answer an error without a session.""" + controller = _controller(stream_camera()) + value = "AQ==" + solicit = await controller._set_solicit_offer(value, CLIENT) + answer = await controller._set_provide_answer(value, CLIENT) + end = await controller._set_streaming_control(value, CLIENT) + assert base64_to_bytes(solicit) == bytes.fromhex("040102") + assert _status(answer) == hksv3.WebRTCSessionStatus(None, 3) + assert _status(end) == hksv3.WebRTCSessionStatus(None, 3) + + +@pytest.mark.parametrize( + ("sdp", "candidates"), + [ + ("a" * (hksv3.MAX_SDP_SIZE + 1), []), + ("v=0", [hksv3.IceCandidate("a" * hksv3.MAX_ICE_CANDIDATE_SIZE)]), + ], + ids=["sdp", "candidate"], +) +async def test_oversized_answer( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + sdp: str, + candidates: list[hksv3.IceCandidate], +) -> None: + """Test an answer above a size limit answers an error before the delegate.""" + camera = stream_camera() + session = await _session(camera) + request = hksv3.WebRTCProvideAnswer(session.id, sdp, candidates) + + answer = await _controller(camera)._set_provide_answer( + to_base64_str(request.encode()), CLIENT + ) + + assert _status(answer) == hksv3.WebRTCSessionStatus(None, 3) + assert session.state is SessionState.SOLICITED + webrtc_delegate.handle_answer.assert_not_called() diff --git a/tests/test_camera_webrtc_hap.py b/tests/test_camera_webrtc_hap.py new file mode 100644 index 00000000..209f5b60 --- /dev/null +++ b/tests/test_camera_webrtc_hap.py @@ -0,0 +1,115 @@ +"""Tests for WebRTC writes of pyhap.camera through the accessory driver.""" + +import asyncio +from collections.abc import Callable +import inspect +from typing import Any +from unittest.mock import AsyncMock + +from pyhap import hksv3 +from pyhap.accessory import Bridge +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import Camera +from pyhap.util import base64_to_bytes, to_base64_str + +WEBRTC = "CameraWebRTCStreamManagement" +CLIENT = ("192.168.1.114", 50000) +OTHER = ("192.168.1.115", 51000) +SOLICIT = to_base64_str( + hksv3.WebRTCSolicitOffer(hksv3.WebRTCOfferOptions(True)).encode() +) + + +def _bridged(driver: AccessoryDriver, camera: Camera) -> Camera: + bridge = Bridge(driver, "Bridge") + bridge.add_accessory(camera) + driver.add_accessory(bridge) + return camera + + +def _char_id(camera: Camera, name: str) -> tuple[int, int]: + service = camera.get_service(WEBRTC) + assert service is not None + iid = camera.iid_manager.get_iid(service.get_characteristic(name)) + assert camera.aid is not None + assert iid is not None + return camera.aid, iid + + +async def _write( + driver: AccessoryDriver, + camera: Camera, + name: str, + value: str, + timeout: float = 9.0, +) -> dict[str, Any]: + """Write like a controller that asks for a write response.""" + aid, iid = _char_id(camera, name) + query = {"characteristics": [{"aid": aid, "iid": iid, "value": value, "r": True}]} + result = driver._set_characteristics_for_controller(query, CLIENT, timeout) + assert inspect.isawaitable(result) + response = await result + assert response is not None + return dict(response["characteristics"][0]) + + +def _read( + driver: AccessoryDriver, camera: Camera, name: str, client: tuple[str, int] +) -> Any: + aid, iid = _char_id(camera, name) + response = driver.get_characteristics([f"{aid}.{iid}"], client) + return response["characteristics"][0]["value"] + + +async def test_responses_read_back_per_connection( + stream_driver: AccessoryDriver, stream_camera: Callable[..., Camera] +) -> None: + """Test only the writing controller reads the offer and the answer status.""" + camera = _bridged(stream_driver, stream_camera()) + + result = await _write(stream_driver, camera, "WebRTCSolicitOffer", SOLICIT) + + assert result["status"] == 0 + offer = hksv3.WebRTCSolicitOfferResponse.decode(base64_to_bytes(result["value"])) + assert offer.sdp_offer == "v=0\r\n" + assert offer.session_id is not None + assert ( + _read(stream_driver, camera, "WebRTCSolicitOffer", CLIENT) == (result["value"]) + ) + assert _read(stream_driver, camera, "WebRTCSolicitOffer", OTHER) == "" + + answer = hksv3.WebRTCProvideAnswer(offer.session_id, "v=0\r\n") + result = await _write( + stream_driver, camera, "WebRTCProvideAnswer", to_base64_str(answer.encode()) + ) + assert hksv3.WebRTCSessionStatus.decode( + base64_to_bytes(result["value"]) + ) == hksv3.WebRTCSessionStatus(offer.session_id, hksv3.WebRTCStreamingStatus(0)) + assert _read(stream_driver, camera, "WebRTCProvideAnswer", OTHER) == "" + assert _read(stream_driver, camera, "WebRTCNumberOfActiveSessions", OTHER) == 1 + await camera.stop() + + +async def test_slow_hook_answers_error_not_timeout( + stream_driver: AccessoryDriver, + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, +) -> None: + """Test a slow hook gives the controller Error before the HAP timeout.""" + camera = _bridged( + stream_driver, + stream_camera(webrtc={"delegate": webrtc_delegate, "hook_timeout": 0.05}), + ) + webrtc_delegate.handle_solicit_offer.side_effect = _never + + result = await _write( + stream_driver, camera, "WebRTCSolicitOffer", SOLICIT, timeout=1.0 + ) + + assert result["status"] == 0 + assert base64_to_bytes(result["value"]) == bytes.fromhex("040102") + await camera.stop() + + +async def _never(*_: Any) -> None: + await asyncio.Event().wait() From c8e3fc483b31634c3b3b5ef37ca8365b88b0b0aa Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:43:53 +0100 Subject: [PATCH 32/56] Renegotiate HKSV 17.99 WebRTC sessions and update their keys An active WebRTC session can be renegotiated with WebRTC Reoffer: the controller offers, the delegate's handle_reoffer() answers, optionally with a new SFrame key. WebRTC Update Session hands the delegate the SFrame keys the controller adds and the key IDs it removes. Both need an active session of the writing connection. A request while another hook of the session runs answers busy. A failure answers its status and keeps the session, a hook that takes too long ends it. A reoffer above the SDP limit answers an error before the delegate sees it. --- pyhap/camera_webrtc.py | 64 ++++++++++++++ tests/test_camera_webrtc.py | 170 ++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) diff --git a/pyhap/camera_webrtc.py b/pyhap/camera_webrtc.py index 4f0a7648..f5510b84 100644 --- a/pyhap/camera_webrtc.py +++ b/pyhap/camera_webrtc.py @@ -238,6 +238,8 @@ def __init__( ("WebRTCSolicitOffer", self._set_solicit_offer), ("WebRTCProvideAnswer", self._set_provide_answer), ("WebRTCStreamingControl", self._set_streaming_control), + ("WebRTCReoffer", self._set_reoffer), + ("WebRTCUpdateSession", self._set_update_session), ): service.configure_char(name, setter_callback=setter) @@ -400,3 +402,65 @@ async def _set_streaming_control( return _status(session.id, _STATUS.ERROR) ended = await self._end_by_request(session, deadline) return _status(session.id, _STATUS.SUCCESS if ended else _STATUS.ERROR) + + def _active_session( + self, session_id: UUID, client_addr: Any + ) -> WebRTCSession | hksv3.WebRTCStreamingStatus: + """The session to renegotiate or update, else the status to answer.""" + session = self._owned(session_id, client_addr) + if session is None: + return _STATUS.UNKNOWN_SESSION + if session.state is not SessionState.ACTIVE: + return _STATUS.ERROR + if session.busy: + return _STATUS.BUSY + return session + + async def _set_reoffer(self, value: str, sender_client_addr: Any = None) -> str: + deadline = self._deadline() + request = _decode("Reoffer", hksv3.WebRTCReoffer.decode, value) + if request is None: + response = hksv3.WebRTCReofferResponse(None, _STATUS.ERROR) + else: + response = await self._reoffer(request, sender_client_addr, deadline) + return to_base64_str(response.encode()) + + async def _reoffer( + self, request: hksv3.WebRTCReoffer, client_addr: Any, deadline: float + ) -> hksv3.WebRTCReofferResponse: + session = self._active_session(request.session_id, client_addr) + if not isinstance(session, WebRTCSession): + return hksv3.WebRTCReofferResponse(request.session_id, session) + hook = self._delegate.handle_reoffer( + session, request.sdp_offer, request.options + ) + try: + answer = await self._run_hook(session, "reoffer", hook, deadline) + except HookFailed as err: + return hksv3.WebRTCReofferResponse( + session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + ) + return hksv3.WebRTCReofferResponse( + session.id, _STATUS.SUCCESS, answer.sdp, answer.sframe + ) + + async def _set_update_session( + self, value: str, sender_client_addr: Any = None + ) -> str: + deadline = self._deadline() + request = _decode("Update Session", hksv3.WebRTCUpdateSession.decode, value) + if request is None: + return _status(None, _STATUS.ERROR) + session = self._active_session(request.session_id, sender_client_addr) + if not isinstance(session, WebRTCSession): + return _status(request.session_id, session) + hook = self._delegate.handle_update_session( + session, request.add_keys, request.remove_kids + ) + try: + await self._run_hook(session, "update session", hook, deadline) + except HookFailed as err: + return _status( + session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + ) + return _status(session.id, _STATUS.SUCCESS) diff --git a/tests/test_camera_webrtc.py b/tests/test_camera_webrtc.py index c6a074ec..88255b87 100644 --- a/tests/test_camera_webrtc.py +++ b/tests/test_camera_webrtc.py @@ -494,3 +494,173 @@ async def test_oversized_answer( assert _status(answer) == hksv3.WebRTCSessionStatus(None, 3) assert session.state is SessionState.SOLICITED webrtc_delegate.handle_answer.assert_not_called() + + +# Renegotiation + + +async def _reoffer( + camera: Camera, session_id: UUID, client: Any = CLIENT +) -> hksv3.WebRTCReofferResponse: + request = hksv3.WebRTCReoffer( + session_id, "v=0\r\na=offer\r\n", hksv3.WebRTCOfferOptions(True) + ) + response = await _controller(camera)._set_reoffer( + to_base64_str(request.encode()), client + ) + return hksv3.WebRTCReofferResponse.decode(base64_to_bytes(response)) + + +async def _update( + camera: Camera, session_id: UUID, client: Any = CLIENT +) -> hksv3.WebRTCSessionStatus: + request = hksv3.WebRTCUpdateSession(session_id, [KEY], [5]) + return _status( + await _controller(camera)._set_update_session( + to_base64_str(request.encode()), client + ) + ) + + +async def test_reoffer( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a reoffer of an active session is answered.""" + camera = stream_camera() + session = await _active(camera) + + response = await _reoffer(camera, session.id) + + assert response == hksv3.WebRTCReofferResponse(session.id, 0, "v=0\r\na=answer\r\n") + webrtc_delegate.handle_reoffer.assert_awaited_once_with( + session, "v=0\r\na=offer\r\n", hksv3.WebRTCOfferOptions(True) + ) + + +async def test_update_session( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test the controller's SFrame keys reach the delegate.""" + camera = stream_camera() + session = await _active(camera) + + assert (await _update(camera, session.id)).status == 0 + + webrtc_delegate.handle_update_session.assert_awaited_once_with(session, [KEY], [5]) + + +@pytest.mark.parametrize( + ("add_keys", "remove_kids"), + [ + ([KEY] * (hksv3.MAX_SFRAME_KEY_CHANGES + 1), []), + ([], [5] * (hksv3.MAX_SFRAME_KEY_CHANGES + 1)), + ], + ids=["add-keys", "remove-kids"], +) +async def test_oversized_update_session( + stream_camera: Callable[..., Camera], + webrtc_delegate: AsyncMock, + add_keys: list[hksv3.SFrameKey], + remove_kids: list[int], +) -> None: + """Test an update above the key limit answers an error before the delegate.""" + camera = stream_camera() + session = await _active(camera) + request = hksv3.WebRTCUpdateSession(session.id, add_keys, remove_kids) + + response = await _controller(camera)._set_update_session( + to_base64_str(request.encode()), CLIENT + ) + + assert _status(response) == hksv3.WebRTCSessionStatus(None, 3) + assert session.state is SessionState.ACTIVE + webrtc_delegate.handle_update_session.assert_not_called() + + +async def test_renegotiation_needs_an_active_session( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test unknown, foreign and unanswered sessions are refused.""" + camera = stream_camera() + session = await _session(camera) + + assert (await _reoffer(camera, session.id)).status == 3 + assert (await _update(camera, session.id)).status == 3 + assert (await _reoffer(camera, uuid4())).status == 1 + assert (await _update(camera, session.id, OTHER)).status == 1 + webrtc_delegate.handle_reoffer.assert_not_called() + webrtc_delegate.handle_update_session.assert_not_called() + + +async def test_renegotiation_fails( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a failed reoffer or update answers its status, the session stays.""" + webrtc_delegate.handle_reoffer.side_effect = RuntimeError + webrtc_delegate.handle_update_session.side_effect = StreamError(2) + camera = stream_camera() + session = await _active(camera) + + assert (await _reoffer(camera, session.id)).status == 3 + assert (await _update(camera, session.id)).status == 2 + assert session.state is SessionState.ACTIVE + + +async def test_renegotiation_while_busy( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an update while a reoffer runs is busy.""" + answered = asyncio.Event() + + async def _answer_reoffer(*_: Any) -> hksv3.WebRTCReofferAnswer: + await answered.wait() + return hksv3.WebRTCReofferAnswer("v=0\r\n") + + webrtc_delegate.handle_reoffer.side_effect = _answer_reoffer + camera = stream_camera() + session = await _active(camera) + reoffer = asyncio.ensure_future(_reoffer(camera, session.id)) + await asyncio.sleep(0) + + assert (await _update(camera, session.id)).status == 2 + + answered.set() + assert (await reoffer).status == 0 + + +async def test_slow_reoffer_ends_the_session( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a reoffer beyond hook_timeout ends the session in the background.""" + + async def _never(*_: Any) -> None: + await asyncio.Event().wait() + + webrtc_delegate.handle_reoffer.side_effect = _never + camera = stream_camera(webrtc={"delegate": webrtc_delegate, "hook_timeout": 0.05}) + session = await _active(camera) + + assert (await _reoffer(camera, session.id)).status == 3 + await camera.stop() + + assert session.state is SessionState.ENDED + assert _count(camera) == 0 + webrtc_delegate.handle_end.assert_awaited_once_with(session) + + +async def test_oversized_reoffer( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test a reoffer above the SDP limit answers an error before the delegate.""" + camera = stream_camera() + session = await _active(camera) + request = hksv3.WebRTCReoffer(session.id, "a" * (hksv3.MAX_SDP_SIZE + 1)) + + response = await _controller(camera)._set_reoffer( + to_base64_str(request.encode()), CLIENT + ) + + assert hksv3.WebRTCReofferResponse.decode(base64_to_bytes(response)) == ( + hksv3.WebRTCReofferResponse(None, 3) + ) + webrtc_delegate.handle_reoffer.assert_not_called() From 03ef22900dcdf0cb5be6d1060c679c397aa27840 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:13 +0100 Subject: [PATCH 33/56] Make pylibsrtp a dependency Talkback, multi-tier streams and the media package need SRTP; the Talkback extra stays as an empty alias for existing installs. --- README.md | 1 - pyhap/camera.py | 10 ++-------- pyhap/camera_talkback.py | 15 +++------------ pyproject.toml | 6 +++--- requirements_test.txt | 4 ++-- tests/test_camera.py | 3 +-- tox.ini | 3 --- 7 files changed, 11 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1400a9b0..625fb2ee 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ This will install HAP-python in your Python packages, so that you can import it `pyhap`. The following optional features can be installed via pip extras: * `QRCode` — print a pairing QR code in the terminal (`base36`, `pyqrcode`). -* `Talkback` — two-way audio for the camera accessory (`pylibsrtp`). * `bench` — dependencies for running the benchmarks (`psutil`, `py-spy`). ## Demo diff --git a/pyhap/camera.py b/pyhap/camera.py index 64602f1a..736c2cdc 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -39,7 +39,7 @@ tlv, ) from pyhap.accessory import Accessory -from pyhap.camera_talkback import TalkbackReceiver, talkback_available +from pyhap.camera_talkback import TalkbackReceiver from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_CAMERA from pyhap.service import Service @@ -595,8 +595,7 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: - srtp - boolean, defaults to False. Whether the camera supports SRTP. - talkback - boolean, defaults to False. Whether the camera supports receiving two-way ("talkback") audio from the controller during a - live view session. Requires the optional ``pylibsrtp`` dependency - (``pip install HAP-python[Talkback]``). When enabled, a "Speaker" + live view session. When enabled, a "Speaker" service is added so that HAP clients know the accessory supports it, and :meth:`talkback_audio_received` is called with the received audio for each session that negotiates it. @@ -644,11 +643,6 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: """ self.has_srtp = options.get("srtp", False) self.support_talkback = options.get("talkback", False) - if self.support_talkback and not talkback_available(): - raise ValueError( - "The 'talkback' option requires the optional 'pylibsrtp' " - "dependency. Install it with: pip install HAP-python[Talkback]" - ) self.start_stream_cmd = options.get("start_stream_cmd", FFMPEG_CMD) self.stream_address = options["address"] diff --git a/pyhap/camera_talkback.py b/pyhap/camera_talkback.py index fcfec43c..04305356 100644 --- a/pyhap/camera_talkback.py +++ b/pyhap/camera_talkback.py @@ -31,10 +31,7 @@ import threading from typing import cast -try: - import pylibsrtp -except ImportError: # pragma: no cover - exercised via requires_talkback() - pylibsrtp = None +import pylibsrtp logger = logging.getLogger(__name__) @@ -42,8 +39,8 @@ def talkback_available() -> bool: - """Whether the optional dependency for talkback support is installed.""" - return pylibsrtp is not None + """Whether talkback is supported, always since pylibsrtp is a dependency.""" + return True @dataclass @@ -148,12 +145,6 @@ def __init__( is_ipv6: bool = False, controller_address: str | None = None, ) -> None: - if pylibsrtp is None: - raise RuntimeError( - "Talkback support requires the 'pylibsrtp' package. " - "Install it with: pip install HAP-python[Talkback]" - ) - policy = pylibsrtp.Policy( key=srtp_key_and_salt, ssrc_type=pylibsrtp.Policy.SSRC_ANY_INBOUND, diff --git a/pyproject.toml b/pyproject.toml index 841aec15..3df329f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "orjson>=3.7.2", "zeroconf>=0.36.2", "h11", + "pylibsrtp", ] dynamic = ["version"] @@ -35,9 +36,8 @@ QRCode = [ "base36", "pyqrcode", ] -Talkback = [ - "pylibsrtp", -] +# Kept for existing installs: two-way audio needs nothing extra. +Talkback = [] bench = [ "psutil", "py-spy", diff --git a/requirements_test.txt b/requirements_test.txt index c89abf07..53974d6d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ # Test/lint tooling only. Library dependencies (including the optional -# QRCode/Talkback extras) come from the package itself - tox installs it, -# so use e.g. `pip install -e ".[QRCode,Talkback]"` in a dev venv. +# QRCode extra) come from the package itself - tox installs it, so use e.g. +# `pip install -e ".[QRCode]"` in a dev venv. pytest pytest-asyncio pytest-cov diff --git a/tests/test_camera.py b/tests/test_camera.py index c6c55a14..4def0bb5 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch from uuid import UUID +import pylibsrtp import pytest from pyhap import camera, tlv @@ -289,8 +290,6 @@ async def test_talkback_audio_is_received_and_dispatched(mock_driver: Any) -> No client would during a live view session) is decrypted, RTP-parsed, and dispatched to ``talkback_audio_received`` with the raw codec payload. """ - pylibsrtp = pytest.importorskip("pylibsrtp") - mock_driver.loop = asyncio.get_running_loop() received = [] diff --git a/tox.ini b/tox.ini index f245245d..a221cd00 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,6 @@ python = # [project.dependencies] are the single source of truth for runtime deps. extras = QRCode - Talkback deps = -r{toxinidir}/requirements_test.txt commands = @@ -22,7 +21,6 @@ commands = [testenv:codecov] extras = QRCode - Talkback deps = -r{toxinidir}/requirements_test.txt commands = @@ -58,7 +56,6 @@ commands = [testenv:ty] extras = QRCode - Talkback # benchmarks/ is in [tool.ty.src], so the type checker needs its deps too. bench deps = From 0f0f67c5d680064c26840bda582c2a93316055f2 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:13 +0100 Subject: [PATCH 34/56] Add RTP, RTCP, H.265 RTP and SDP helpers for camera media The building blocks of pyhap.media: RTP and RTCP parsing and building (RFC 3550, RFC 4585), an SRTP output stream, H.265 RTP depacketizing and packetizing (RFC 7798) and the SDP and codec parameters of HomeKit camera streams. --- pyhap/media/__init__.py | 71 +++++ pyhap/media/hevc.py | 330 +++++++++++++++++++++ pyhap/media/rtp.py | 292 +++++++++++++++++++ pyhap/media/sdp.py | 616 +++++++++++++++++++++++++++++++++++++++ tests/test_media_hevc.py | 316 ++++++++++++++++++++ tests/test_media_rtp.py | 147 ++++++++++ tests/test_media_sdp.py | 472 ++++++++++++++++++++++++++++++ tests/webrtc_helpers.py | 93 ++++++ 8 files changed, 2337 insertions(+) create mode 100644 pyhap/media/__init__.py create mode 100644 pyhap/media/hevc.py create mode 100644 pyhap/media/rtp.py create mode 100644 pyhap/media/sdp.py create mode 100644 tests/test_media_hevc.py create mode 100644 tests/test_media_rtp.py create mode 100644 tests/test_media_sdp.py create mode 100644 tests/webrtc_helpers.py diff --git a/pyhap/media/__init__.py b/pyhap/media/__init__.py new file mode 100644 index 00000000..a0209394 --- /dev/null +++ b/pyhap/media/__init__.py @@ -0,0 +1,71 @@ +"""Shared media of HomeKit camera streams. + +:class:`MediaSource` receives a camera channel as plain RTP and reassembles +its H.265 once for every session of the channel. :class:`SourcePool` shares +one source and one pull (e.g. :class:`FFmpegSourceProvider`) per channel, +and :class:`MediaWorker` runs the sources in a worker process. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .sdp import AudioParams, IceCandidateLike, VideoParams + + +class MediaError(Exception): + """A media session cannot do what was asked.""" + + +if TYPE_CHECKING: + from .ffmpeg import FFmpegInput, FFmpegSourceProvider + from .pool import ( + ChannelLease, + ChannelPull, + MediaSourceLike, + SourceLease, + SourcePool, + ) + from .source import MediaSource + from .worker import ( + MediaWorker, + RemoteMediaSource, + ) + +__all__ = [ + "AudioParams", + "ChannelLease", + "ChannelPull", + "FFmpegInput", + "FFmpegSourceProvider", + "IceCandidateLike", + "MediaSource", + "MediaSourceLike", + "RemoteMediaSource", + "SourceLease", + "SourcePool", + "VideoParams", + "MediaError", + "MediaWorker", +] + +_LAZY = { + "ChannelLease": "pool", + "ChannelPull": "pool", + "FFmpegInput": "ffmpeg", + "FFmpegSourceProvider": "ffmpeg", + "MediaSourceLike": "pool", + "SourceLease": "pool", + "SourcePool": "pool", + "MediaSource": "source", + "RemoteMediaSource": "worker", + "MediaWorker": "worker", +} + + +def __getattr__(name: str) -> Any: + if name in _LAZY: + from importlib import import_module # noqa: PLC0415 + + return getattr(import_module(f".{_LAZY[name]}", __name__), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/pyhap/media/hevc.py b/pyhap/media/hevc.py new file mode 100644 index 00000000..2881b007 --- /dev/null +++ b/pyhap/media/hevc.py @@ -0,0 +1,330 @@ +"""H.265 RTP payload handling (RFC 7798) for the camera media senders. + +The integrator's ffmpeg sends HEVC as RTP (``-c:v copy -bsf:v dump_extra -f +rtp``). That suits Annex B inputs such as RTSP; an MP4 or other hvcC input +needs ``-bsf:v hevc_mp4toannexb`` instead, which also puts the parameter sets +in front of every IRAP picture. SFrame protects whole frames, so the sender +turns that RTP back into access units, writes each one as 4-byte +length-prefixed NAL units (the hvcC sample layout) and encrypts the result. + +Only the RFC 7798 packet types without DONL are handled: single NAL unit +packets, aggregation packets (type 48) and fragmentation units (type 49). The +receiver needs VPS, SPS and PPS in-band in front of every IRAP picture, since +the SDP carries no sprop parameter sets; parameter sets seen earlier are +inserted when an IRAP access unit arrives without them. + +The SFrame plaintext matches what Scrypted's HomeKit WebRTC plugin sends for +the same RTP input (https://github.com/koush/scrypted, ``plugins/webrtc``). + +:class:`HevcPacketizer` is the reverse direction, for sessions without +SFrame, which send plain RFC 7798 RTP. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +import logging +import struct + +logger = logging.getLogger(__name__) + +NAL_HEADER_SIZE = 2 +NAL_VPS = 32 +NAL_SPS = 33 +NAL_PPS = 34 +NAL_AP = 48 +NAL_FU = 49 +IRAP_FIRST = 16 +IRAP_LAST = 23 +PARAMETER_SETS = (NAL_VPS, NAL_SPS, NAL_PPS) +MAX_ACCESS_UNIT = 16 * 1024 * 1024 + + +def nal_type(nal: bytes) -> int: + """Return the NAL unit type of an H.265 NAL unit or RTP payload.""" + return (nal[0] >> 1) & 0x3F + + +def is_irap(nal: bytes) -> bool: + """Whether a NAL unit is an IRAP (BLA, IDR or CRA) picture slice.""" + return IRAP_FIRST <= nal_type(nal) <= IRAP_LAST + + +def payload_starts_irap(payload: bytes) -> bool: + """Whether an RTP payload starts or carries an IRAP slice. + + A cheap look at the payload header (and the FU header or AP units), + without reassembling anything. + """ + if len(payload) < NAL_HEADER_SIZE + 1: + return False + kind = nal_type(payload) + if kind == NAL_FU: + return bool(payload[2] & 0x80) and IRAP_FIRST <= payload[2] & 0x3F <= IRAP_LAST + if kind == NAL_AP: + return any(is_irap(nal) for nal in split_aggregation_packet(payload) or ()) + return is_irap(payload) + + +def length_prefixed(nals: Iterable[bytes]) -> bytes: + """Join NAL units with 4-byte big-endian length prefixes.""" + return b"".join(struct.pack(">I", len(nal)) + nal for nal in nals) + + +def split_length_prefixed(data: bytes) -> list[bytes]: + """Split 4-byte length-prefixed NAL units.""" + nals = [] + offset = 0 + while offset < len(data): + if offset + 4 > len(data): + raise ValueError("Truncated NAL unit length") + (length,) = struct.unpack_from(">I", data, offset) + offset += 4 + if offset + length > len(data): + raise ValueError("Truncated NAL unit") + nals.append(data[offset : offset + length]) + offset += length + return nals + + +def split_aggregation_packet(payload: bytes) -> list[bytes] | None: + """Return the NAL units of an AP, or ``None`` when it is malformed.""" + nals = [] + offset = NAL_HEADER_SIZE + while offset < len(payload): + if offset + 2 > len(payload): + return None + (length,) = struct.unpack_from(">H", payload, offset) + offset += 2 + if length < NAL_HEADER_SIZE or offset + length > len(payload): + return None + nals.append(payload[offset : offset + length]) + offset += length + return nals or None + + +@dataclass +class AccessUnit: + """All NAL units of one picture, sharing one RTP timestamp.""" + + timestamp: int + nals: list[bytes] = field(default_factory=list) + + @property + def keyframe(self) -> bool: + """Whether the access unit contains an IRAP picture.""" + return any(is_irap(nal) for nal in self.nals) + + def to_bytes(self) -> bytes: + """Return the 4-byte length-prefixed SFrame plaintext.""" + return length_prefixed(self.nals) + + +class HevcDepacketizer: + """Reassembles access units from RFC 7798 RTP payloads. + + An access unit ends with the RTP marker bit. A sequence gap, a broken + fragmentation unit or an unsupported packet type damages the access unit, + which is then dropped; the next one starts clean. + """ + + def __init__(self) -> None: + """Start without cached parameter sets.""" + self.parameter_sets: dict[int, bytes] = {} + self.dropped = 0 + self._timestamp: int | None = None + self._last_sequence: int | None = None + self._nals: list[bytes] = [] + self._size = 0 + self._fragment: list[bytes] | None = None + self._damaged = False + + def reset(self) -> None: + """Drop the access unit in progress, for example on a new input SSRC. + + Cached parameter sets go too: a new source sends its own in-band, and + an old set must not be put in front of its pictures. + """ + self.parameter_sets = {} + self.restart() + + def restart(self) -> None: + """Drop the access unit in progress and forget the last sequence. + + Cached parameter sets stay, for a pause in the same input stream. + """ + self._last_sequence = None + self._reset() + + def push( + self, sequence: int, timestamp: int, marker: bool, payload: bytes + ) -> AccessUnit | None: + """Add one RTP payload; return the access unit completed by it.""" + if self._timestamp is not None and timestamp != self._timestamp: + # A new timestamp without a marker: the previous unit is incomplete. + self._drop() + if self._timestamp is None: + self._timestamp = timestamp + if ( + self._last_sequence is not None + and sequence != (self._last_sequence + 1) & 0xFFFF + ): + self._damaged = True + self._last_sequence = sequence + if not self._damaged: + self._add_payload(payload) + if not marker: + return None + if self._fragment is not None: + self._damaged = True + if self._damaged or not self._nals: + self._drop() + return None + unit = AccessUnit(timestamp, self._complete(self._nals)) + self._reset() + return unit + + def _add_payload(self, payload: bytes) -> None: + if len(payload) < NAL_HEADER_SIZE: + self._damaged = True + return + kind = nal_type(payload) + if kind == NAL_FU: + self._add_fragment(payload) + return + if self._fragment is not None: + self._damaged = True + return + if kind == NAL_AP: + nals = split_aggregation_packet(payload) + if nals is None: + self._damaged = True + return + for nal in nals: + self._add_nal(nal) + return + if kind > NAL_AP: + self._damaged = True + return + self._add_nal(payload) + + def _add_fragment(self, payload: bytes) -> None: + if len(payload) < 3: + self._damaged = True + return + fu_header = payload[2] + start, end = fu_header & 0x80, fu_header & 0x40 + if start: + if self._fragment is not None: + self._damaged = True + return + header = bytes([(payload[0] & 0x81) | (fu_header & 0x3F) << 1, payload[1]]) + self._fragment = [header] + elif self._fragment is None: + self._damaged = True + return + self._fragment.append(payload[3:]) + self._size += len(payload) - 3 + if self._size > MAX_ACCESS_UNIT: + self._damaged = True + return + if end: + nal = b"".join(self._fragment) + self._fragment = None + self._size -= len(nal) - NAL_HEADER_SIZE + self._add_nal(nal) + + def _add_nal(self, nal: bytes) -> None: + self._size += 4 + len(nal) + if self._size > MAX_ACCESS_UNIT: + # Bound memory when a source never sets the marker bit. + self._damaged = True + return + kind = nal_type(nal) + if kind in PARAMETER_SETS: + self.parameter_sets[kind] = nal + self._nals.append(nal) + + def _complete(self, nals: list[bytes]) -> list[bytes]: + """Put cached parameter sets in front of an IRAP that lacks them.""" + if not any(is_irap(nal) for nal in nals): + return nals + present = {nal_type(nal) for nal in nals} + missing = [ + self.parameter_sets[kind] + for kind in PARAMETER_SETS + if kind not in present and kind in self.parameter_sets + ] + return missing + nals + + def _drop(self) -> None: + if self._nals or self._fragment is not None or self._damaged: + self.dropped += 1 + self._reset() + + def _reset(self) -> None: + self._timestamp = None + self._nals = [] + self._size = 0 + self._fragment = None + self._damaged = False + + +def fragment(nal: bytes, max_payload: int) -> list[bytes]: + """Split one NAL unit into RFC 7798 fragmentation units.""" + kind = nal_type(nal) + header = bytes([(nal[0] & 0x81) | NAL_FU << 1, nal[1]]) + chunk = max_payload - 3 + body = nal[NAL_HEADER_SIZE:] + payloads = [] + for offset in range(0, len(body), chunk): + flags = (0x80 if offset == 0 else 0) | ( + 0x40 if offset + chunk >= len(body) else 0 + ) + payloads.append(header + bytes([flags | kind]) + body[offset : offset + chunk]) + return payloads + + +class HevcPacketizer: + """Packetizes access units into RFC 7798 RTP payloads (no DONL).""" + + def __init__(self, max_payload: int = 1200) -> None: + """Set the RTP payload budget.""" + if max_payload < 4: + raise ValueError("Payload budget too small for H.265") + self.max_payload = max_payload + + def packetize(self, nals: Iterable[bytes]) -> list[bytes]: + """Return the RTP payloads of one access unit, in order.""" + payloads: list[bytes] = [] + pending: list[bytes] = [] + for nal in nals: + if len(nal) > self.max_payload: + payloads += self._aggregate(pending) + pending = [] + payloads += self._fragment(nal) + continue + if self._ap_size([*pending, nal]) > self.max_payload: + payloads += self._aggregate(pending) + pending = [] + pending.append(nal) + payloads += self._aggregate(pending) + return payloads + + @staticmethod + def _ap_size(nals: list[bytes]) -> int: + return NAL_HEADER_SIZE + sum(2 + len(nal) for nal in nals) + + @staticmethod + def _aggregate(nals: list[bytes]) -> list[bytes]: + if len(nals) <= 1: + return list(nals) + # The AP header carries the lowest LayerId and TID (RFC 7798 4.4.2). + layer = min(((nal[0] & 1) << 5) | (nal[1] >> 3) for nal in nals) + tid = min(nal[1] & 7 for nal in nals) + header = bytes([NAL_AP << 1 | layer >> 5, (layer & 0x1F) << 3 | tid]) + return [header + b"".join(struct.pack(">H", len(n)) + n for n in nals)] + + def _fragment(self, nal: bytes) -> list[bytes]: + return fragment(nal, self.max_payload) diff --git a/pyhap/media/rtp.py b/pyhap/media/rtp.py new file mode 100644 index 00000000..b1f9721e --- /dev/null +++ b/pyhap/media/rtp.py @@ -0,0 +1,292 @@ +"""Minimal RTP and RTCP handling for the camera media senders (RFC 3550, RFC 4585).""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Iterable +from dataclasses import dataclass, field +import secrets +import struct +import time + +# Shortest interval between keyframe requests to a source. +KEYFRAME_REQUEST_INTERVAL = 1.0 +# An audio gap this long starts a new talkspurt (RTP marker bit). +AUDIO_TALKSPURT_GAP = 1.0 +# Sent packets kept for NACK retransmission. +RETRANSMIT_HISTORY = 1024 + + +RTP_VERSION = 2 +RTP_HEADER = 12 +RTCP_SR = 200 +RTCP_RR = 201 +RTCP_SDES = 202 +RTCP_BYE = 203 +RTCP_RTPFB = 205 +RTCP_PSFB = 206 +FMT_NACK = 1 +FMT_PLI = 1 +FMT_FIR = 4 +NTP_EPOCH_OFFSET = 2208988800 +# Payload types 64-95 collide with RTCP packet types (RFC 5761 section 4). +RTCP_PAYLOAD_TYPES = range(64, 96) + + +@dataclass +class RtpPacket: + """A parsed RTP packet; header extensions and CSRCs are dropped.""" + + payload_type: int + sequence: int + timestamp: int + ssrc: int + marker: bool + payload: bytes + + @property + def is_rtcp(self) -> bool: + """Whether this "RTP" packet is really RTCP sent to an RTP port.""" + return self.payload_type in RTCP_PAYLOAD_TYPES + + +def parse_rtp(packet: bytes) -> RtpPacket: + """Parse an RTP packet, skipping CSRCs, extension and padding.""" + if len(packet) < RTP_HEADER or packet[0] >> 6 != RTP_VERSION: + raise ValueError("Not an RTP packet") + first, second, sequence, timestamp, ssrc = struct.unpack_from(">BBHII", packet) + offset = RTP_HEADER + 4 * (first & 0x0F) + if first & 0x10: + if offset + 4 > len(packet): + raise ValueError("Truncated RTP header extension") + (words,) = struct.unpack_from(">H", packet, offset + 2) + offset += 4 + 4 * words + end = len(packet) + if first & 0x20: + if end == 0 or packet[-1] == 0: + raise ValueError("Invalid RTP padding") + end -= packet[-1] + if offset > end: + raise ValueError("Truncated RTP packet") + return RtpPacket( + payload_type=second & 0x7F, + sequence=sequence, + timestamp=timestamp, + ssrc=ssrc, + marker=bool(second & 0x80), + payload=packet[offset:end], + ) + + +def build_rtp(packet: RtpPacket) -> bytes: + """Serialize an RTP packet without CSRCs, extension or padding.""" + second = (0x80 if packet.marker else 0) | packet.payload_type + header = struct.pack( + ">BBHII", + RTP_VERSION << 6, + second, + packet.sequence & 0xFFFF, + packet.timestamp & 0xFFFFFFFF, + packet.ssrc, + ) + return header + packet.payload + + +@dataclass +class RtcpMessage: + """The RTCP fields the sender acts on.""" + + packet_type: int + fmt: int + sender_ssrc: int + media_ssrcs: list[int] = field(default_factory=list) + lost: list[int] = field(default_factory=list) + + @property + def is_keyframe_request(self) -> bool: + """Whether this is a PLI or FIR.""" + return self.packet_type == RTCP_PSFB and self.fmt in (FMT_PLI, FMT_FIR) + + @property + def is_nack(self) -> bool: + """Whether this is a generic NACK.""" + return self.packet_type == RTCP_RTPFB and self.fmt == FMT_NACK + + +def parse_rtcp(data: bytes) -> list[RtcpMessage]: + """Parse a compound RTCP packet; stops at the first malformed part. + + Every part must be version 2, fit in ``data`` and carry at least the + sender SSRC (RFC 3550 section 6.4). + """ + messages = [] + offset = 0 + while offset + 8 <= len(data): + first, packet_type, length = struct.unpack_from(">BBH", data, offset) + end = offset + 4 * (length + 1) + if first >> 6 != RTP_VERSION or length == 0 or end > len(data): + break + body = data[offset + 4 : end] + messages.append(_parse_one(first & 0x1F, packet_type, body)) + offset = end + return messages + + +def _parse_one(fmt: int, packet_type: int, body: bytes) -> RtcpMessage: + (sender,) = struct.unpack_from(">I", body) + message = RtcpMessage(packet_type, fmt, sender) + if packet_type == RTCP_BYE: + message.media_ssrcs = list( + struct.unpack_from(f">{min(fmt, len(body) // 4)}I", body) + ) + elif packet_type in (RTCP_RTPFB, RTCP_PSFB) and len(body) >= 8: + (media,) = struct.unpack_from(">I", body, 4) + fci = body[8:] + if packet_type == RTCP_PSFB and fmt == FMT_FIR: + # FIR names its targets in the FCI (RFC 5104 section 4.3.1). + message.media_ssrcs = [ + struct.unpack_from(">I", fci, i)[0] for i in range(0, len(fci) - 7, 8) + ] + else: + message.media_ssrcs = [media] + if message.is_nack: + message.lost = _nack_sequences(fci) + return message + + +def _nack_sequences(fci: bytes) -> list[int]: + lost: list[int] = [] + for i in range(0, len(fci) - 3, 4): + pid: int + blp: int + pid, blp = struct.unpack_from(">HH", fci, i) + lost.append(pid) + lost += [(pid + bit + 1) & 0xFFFF for bit in range(16) if blp >> bit & 1] + return lost + + +def ntp_time(now: float | None = None) -> int: + """Return a 64-bit NTP timestamp for a Unix time.""" + now = time.time() if now is None else now + seconds = int(now) + NTP_EPOCH_OFFSET + fraction = int((now % 1) * (1 << 32)) + return (seconds & 0xFFFFFFFF) << 32 | fraction + + +def build_sender_report( + ssrc: int, ntp: int, rtp_timestamp: int, packets: int, octets: int +) -> bytes: + """Build an RTCP SR without report blocks.""" + return struct.pack( + ">BBHIQIII", + RTP_VERSION << 6, + RTCP_SR, + 6, + ssrc, + ntp, + rtp_timestamp & 0xFFFFFFFF, + packets & 0xFFFFFFFF, + octets & 0xFFFFFFFF, + ) + + +def build_receiver_report(ssrc: int) -> bytes: + """Build an RTCP RR without report blocks.""" + return struct.pack(">BBHI", RTP_VERSION << 6, RTCP_RR, 1, ssrc) + + +def build_sdes(ssrc: int, cname: str) -> bytes: + """Build an RTCP SDES with one CNAME chunk.""" + name = cname.encode()[:255] + chunk = struct.pack(">IBB", ssrc, 1, len(name)) + name + b"\x00" + chunk += bytes(-len(chunk) % 4) + header = struct.pack(">BBH", RTP_VERSION << 6 | 1, RTCP_SDES, len(chunk) // 4) + return header + chunk + + +def build_bye(ssrcs: Iterable[int]) -> bytes: + """Build an RTCP BYE for the given sources.""" + sources = list(ssrcs) + header = struct.pack( + ">BBH", RTP_VERSION << 6 | len(sources), RTCP_BYE, len(sources) + ) + return header + b"".join(struct.pack(">I", ssrc) for ssrc in sources) + + +def build_pli(sender_ssrc: int, media_ssrc: int) -> bytes: + """Build an RTCP PLI.""" + return struct.pack( + ">BBHII", RTP_VERSION << 6 | FMT_PLI, RTCP_PSFB, 2, sender_ssrc, media_ssrc + ) + + +def build_nack(sender_ssrc: int, media_ssrc: int, lost: Iterable[int]) -> bytes: + """Build an RTCP generic NACK with one FCI per lost packet.""" + fci = b"".join(struct.pack(">HH", seq & 0xFFFF, 0) for seq in lost) + header = struct.pack( + ">BBHII", + RTP_VERSION << 6 | FMT_NACK, + RTCP_RTPFB, + 2 + len(fci) // 4, + sender_ssrc, + media_ssrc, + ) + return header + fci + + +class OutboundStream: + """SSRC, sequence and timestamp rewriting plus SR and NACK state.""" + + def __init__(self, payload_type: int, clock_rate: int) -> None: + self.ssrc = secrets.randbits(32) + self.payload_type = payload_type + self.clock_rate = clock_rate + self.sequence = secrets.randbits(16) + self.packets = 0 + self.octets = 0 + self._out_base = secrets.randbits(32) + self._in_base: int | None = None + self._in_ssrc: int | None = None + self._last_out: int | None = None + self._last_wall = 0.0 + self.history: OrderedDict[int, bytes] = OrderedDict() + + def timestamp(self, ssrc: int, timestamp: int) -> int: + """Map an input timestamp onto this stream's timeline.""" + now = time.monotonic() + if ssrc != self._in_ssrc or self._in_base is None: + # A new or restarted source continues where the old one ended. + if self._last_out is not None: + elapsed = max(1, round((now - self._last_wall) * self.clock_rate)) + self._out_base = self._last_out + elapsed + self._in_ssrc = ssrc + self._in_base = timestamp + out = (timestamp - self._in_base + self._out_base) & 0xFFFFFFFF + self._last_out = out + self._last_wall = now + return out + + def build(self, timestamp: int, marker: bool, payload: bytes) -> bytes: + """Build the next outgoing RTP packet and remember it for NACK.""" + packet = build_rtp( + RtpPacket( + self.payload_type, self.sequence, timestamp, self.ssrc, marker, payload + ) + ) + self.history[self.sequence] = packet + if len(self.history) > RETRANSMIT_HISTORY: + self.history.popitem(last=False) + self.sequence = (self.sequence + 1) & 0xFFFF + self.packets += 1 + self.octets += len(payload) + return packet + + def sender_report(self) -> bytes | None: + """Build an SR, extrapolating the RTP time to now.""" + if self._last_out is None: + return None + elapsed = time.monotonic() - self._last_wall + rtp_now = self._last_out + round(elapsed * self.clock_rate) + return build_sender_report( + self.ssrc, ntp_time(), rtp_now, self.packets, self.octets + ) diff --git a/pyhap/media/sdp.py b/pyhap/media/sdp.py new file mode 100644 index 00000000..980faceb --- /dev/null +++ b/pyhap/media/sdp.py @@ -0,0 +1,616 @@ +"""SDP for HomeKit WebRTC remote live view. + +The accessory is the offerer. Its offer is sendonly, BUNDLEs one H.265 video +and one Opus audio section, carries all ICE candidates (no trickle) and keeps +``a=setup:actpass`` so the controller picks the DTLS role. + +The video section offers H.265 as the only video codec and carries +``a=rid:1 send`` with ``max-width``, ``max-height``, ``max-fps`` and +``max-br`` but no RID RTP header extension. That matches the offer of +Scrypted's HomeKit WebRTC plugin (https://github.com/koush/scrypted, +``plugins/webrtc``), which is known to play remotely. + +The offer carries every ICE candidate. A controller may still send its +answer's candidates next to the SDP in HAP (RFC 8839 candidate strings with an +optional mid and m-line index, see :class:`IceCandidateLike`); they are merged +into the answer before it is parsed. At most :data:`MAX_CANDIDATES` candidates +are kept per media section. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from itertools import pairwise +from typing import Protocol + +VIDEO_CLOCK_RATE = 90000 +AUDIO_CLOCK_RATE = 48000 +# The payload types HAP uses for the WebRTC stream tiers. +DEFAULT_VIDEO_PAYLOAD_TYPE = 99 +DEFAULT_AUDIO_PAYLOAD_TYPE = 110 +OPUS_PTIME = 20 +VIDEO_MID = "0" +AUDIO_MID = "1" +RID = "1" +DIRECTIONS = ("sendrecv", "sendonly", "recvonly", "inactive") +MAX_CANDIDATES = 32 +MAX_SDP_SIZE = 32 * 1024 +MAX_MEDIA_SECTIONS = 16 + + +class SdpError(ValueError): + """An SDP could not be parsed or does not fit the session.""" + + +class IceCandidateLike(Protocol): + """An ICE candidate as HAP carries it next to the SDP. + + ``candidate`` is the RFC 8839 candidate string, with or without the + ``candidate:`` prefix. + """ + + @property + def candidate(self) -> str: + """The candidate string.""" + + @property + def sdp_mid(self) -> str | None: + """The mid of the media section it belongs to.""" + + @property + def sdp_mline_index(self) -> int | None: + """The index of the media section it belongs to.""" + + +class VideoTierLike(Protocol): + """A HAP video stream tier; bit rates in kbps.""" + + @property + def width(self) -> int: + """Pixels.""" + + @property + def height(self) -> int: + """Pixels.""" + + @property + def frame_rate(self) -> int: + """Frames per second.""" + + @property + def target_bitrate(self) -> int: + """The average bit rate.""" + + @property + def peak_bitrate(self) -> int | None: + """The peak bit rate, if known.""" + + +@dataclass(frozen=True) +class VideoParams: + """The video stream tier the offer advertises. + + ``peak_bitrate_kbps`` goes into ``max-br``; ``target_bitrate_kbps`` (the + average) paces the sender and defaults to the peak. + """ + + width: int + height: int + fps: int + peak_bitrate_kbps: int + payload_type: int = DEFAULT_VIDEO_PAYLOAD_TYPE + target_bitrate_kbps: int | None = None + + @classmethod + def from_tier( + cls, tier: VideoTierLike, payload_type: int = DEFAULT_VIDEO_PAYLOAD_TYPE + ) -> VideoParams: + """Describe a HAP video tier; the peak falls back to the target.""" + return cls( + width=tier.width, + height=tier.height, + fps=tier.frame_rate, + peak_bitrate_kbps=tier.peak_bitrate or tier.target_bitrate, + payload_type=payload_type, + target_bitrate_kbps=tier.target_bitrate, + ) + + @property + def pacing_bitrate_kbps(self) -> int: + """The bit rate the pacer is based on.""" + return self.target_bitrate_kbps or self.peak_bitrate_kbps + + +@dataclass(frozen=True) +class AudioParams: + """The mono Opus audio stream; ``talkback`` makes the section sendrecv.""" + + payload_type: int = DEFAULT_AUDIO_PAYLOAD_TYPE + talkback: bool = False + + +@dataclass(frozen=True) +class LocalParams: + """Transport and stream identifiers of the local side.""" + + ice_ufrag: str + ice_pwd: str + fingerprint: str + cname: str + stream_id: str + video_ssrc: int + audio_ssrc: int | None = None + candidates: Sequence[str] = () + session_id: int = 0 + session_version: int = 2 + + +@dataclass +class MediaDescription: + """One parsed m-section.""" + + kind: str + port: int + protocol: str + formats: list[str] + mid: str | None = None + direction: str = "sendrecv" + rtpmap: dict[int, tuple[str, int, int | None]] = field(default_factory=dict) + fmtp: dict[int, str] = field(default_factory=dict) + ssrcs: dict[int, None] = field(default_factory=dict) + candidates: list[str] = field(default_factory=list) + rids: list[str] = field(default_factory=list) + ice_ufrag: str | None = None + ice_pwd: str | None = None + fingerprints: list[tuple[str, str]] = field(default_factory=list) + setup: str | None = None + + @property + def payload_types(self) -> list[int]: + """The numeric formats of an RTP section.""" + return [int(fmt) for fmt in self.formats if fmt.isascii() and fmt.isdigit()] + + def payload_type_for( + self, codec: str, clock_rate: int, channels: int | None = None + ) -> int | None: + """Return the first payload type mapped to a codec, if any.""" + for payload_type in self.payload_types: + mapping = self.rtpmap.get(payload_type) + if ( + mapping + and mapping[0].lower() == codec.lower() + and mapping[1] == clock_rate + and (channels is None or (mapping[2] or 1) == channels) + ): + return payload_type + return None + + +@dataclass +class SessionDescription: + """A parsed SDP with transport attributes resolved per section.""" + + media: list[MediaDescription] + bundle: list[str] = field(default_factory=list) + ice_lite: bool = False + + def transport(self) -> MediaDescription: + """Return the section whose transport the session uses. + + With BUNDLE that is the first section of the group, otherwise the + first section with a non-zero port. + """ + for media in self.media: + if media.port and (not self.bundle or media.mid == self.bundle[0]): + return media + raise SdpError("SDP has no usable media section") + + @property + def candidates(self) -> list[str]: + """Candidates of the transport sections, without duplicates.""" + seen: dict[str, None] = {} + bundle = set(self.bundle) + for media in self.media: + if media.port and (not bundle or media.mid in bundle): + seen.update(dict.fromkeys(media.candidates)) + return list(seen)[:MAX_CANDIDATES] + + +def _checked_size(sdp: str) -> str: + if len(sdp) > MAX_SDP_SIZE: + raise SdpError(f"SDP larger than {MAX_SDP_SIZE} characters") + return sdp + + +def _split_lines(sdp: str) -> list[str]: + return [line for line in sdp.replace("\r\n", "\n").split("\n") if line] + + +@dataclass +class _SessionAttributes: + ice_ufrag: str | None = None + ice_pwd: str | None = None + setup: str | None = None + fingerprints: list[tuple[str, str]] = field(default_factory=list) + + +def parse(sdp: str) -> SessionDescription: + """Parse an SDP; ICE and DTLS attributes fall back to session level. + + An SDP over :data:`MAX_SDP_SIZE` characters or with more than + :data:`MAX_MEDIA_SECTIONS` media sections is rejected. + """ + session = _SessionAttributes() + desc = SessionDescription(media=[]) + current: MediaDescription | None = None + for line in _split_lines(_checked_size(sdp)): + if line.startswith("m="): + if len(desc.media) == MAX_MEDIA_SECTIONS: + raise SdpError("Too many media sections") + current = _media_line(line) + desc.media.append(current) + continue + if not line.startswith("a="): + continue + name, _, value = line[2:].partition(":") + try: + if current is None: + _session_attribute(desc, session, name, value) + else: + _media_attribute(current, name, value) + except (ValueError, IndexError) as err: + raise SdpError(f"Invalid attribute: {line[:80]!r}") from err + for media in desc.media: + media.ice_ufrag = media.ice_ufrag or session.ice_ufrag + media.ice_pwd = media.ice_pwd or session.ice_pwd + media.setup = media.setup or session.setup + media.fingerprints = media.fingerprints or list(session.fingerprints) + return desc + + +def _media_line(line: str) -> MediaDescription: + parts = line[2:].split() + try: + return MediaDescription( + kind=parts[0], + port=int(parts[1].split("/")[0]), + protocol=parts[2], + formats=parts[3:], + ) + except (ValueError, IndexError) as err: + raise SdpError(f"Invalid m-line: {line[:80]!r}") from err + + +def _fingerprint(value: str) -> tuple[str, str]: + algorithm, _, digest = value.partition(" ") + return algorithm.lower(), digest.strip().upper() + + +def _session_attribute( + desc: SessionDescription, session: _SessionAttributes, name: str, value: str +) -> None: + if name == "group" and value.startswith("BUNDLE"): + desc.bundle = value.split()[1:] + elif name == "ice-lite": + desc.ice_lite = True + elif name == "ice-ufrag": + session.ice_ufrag = value.strip() + elif name == "ice-pwd": + session.ice_pwd = value.strip() + elif name == "setup": + session.setup = value.strip() + elif name == "fingerprint": + session.fingerprints.append(_fingerprint(value)) + + +def _media_attribute(media: MediaDescription, name: str, value: str) -> None: + if name == "mid": + media.mid = value.strip() + elif name in DIRECTIONS and not value: + media.direction = name + elif name == "rtpmap": + pt, _, codec = value.partition(" ") + codec_parts = codec.strip().split("/") + media.rtpmap[int(pt)] = ( + codec_parts[0], + int(codec_parts[1]) if len(codec_parts) > 1 else 0, + int(codec_parts[2]) if len(codec_parts) > 2 else None, + ) + elif name == "fmtp": + pt, _, params = value.partition(" ") + media.fmtp[int(pt)] = params.strip() + elif name == "ssrc": + media.ssrcs[int(value.split(maxsplit=1)[0])] = None + elif name == "candidate" and len(media.candidates) < MAX_CANDIDATES: + media.candidates.append(value.strip()) + elif name == "rid": + media.rids.append(value.strip()) + elif name == "ice-ufrag": + media.ice_ufrag = value.strip() + elif name == "ice-pwd": + media.ice_pwd = value.strip() + elif name == "setup": + media.setup = value.strip() + elif name == "fingerprint": + media.fingerprints.append(_fingerprint(value)) + + +def candidate_value(candidate: str) -> str: + """Strip ``a=`` and ``candidate:`` from a candidate string.""" + value = candidate.strip() + value = value.removeprefix("a=") + return value.removeprefix("candidate:") + + +def merge_candidates(sdp: str, candidates: Iterable[IceCandidateLike]) -> str: + """Add HAP-signaled candidates to the m-sections they belong to. + + A candidate goes to the section with its mid, else to its m-line index, + else to the only section. Candidates already present are not repeated, + and a section takes at most :data:`MAX_CANDIDATES`; once every section is + full the rest is not looked at. + """ + lines = _split_lines(_checked_size(sdp)) + starts = [i for i, line in enumerate(lines) if line.startswith("m=")] + bounds = [*starts, len(lines)] + media = [lines[start:end] for start, end in pairwise(bounds)] + mids: dict[str, int] = {} + counts = [0] * len(media) + present: set[str] = set() + for index, section in enumerate(media): + for line in section: + if line.startswith("a=mid:"): + mids.setdefault(line[6:], index) + elif line.startswith("a=candidate:"): + present.add(line) + counts[index] += 1 + room = sum(max(0, MAX_CANDIDATES - count) for count in counts) + added: list[list[str]] = [[] for _ in media] + for candidate in candidates: + if not room: + break + if not candidate.candidate: + continue + if "\r" in candidate.candidate or "\n" in candidate.candidate: + raise SdpError("Invalid ICE candidate") + line = "a=candidate:" + candidate_value(candidate.candidate) + if line in present: + continue + index = _candidate_section(mids, len(media), candidate) + if counts[index] >= MAX_CANDIDATES: + continue + present.add(line) + counts[index] += 1 + room -= 1 + added[index].append(line) + for section, lines_added in zip(media, added, strict=True): + end = ( + section.index("a=end-of-candidates") + if "a=end-of-candidates" in section + else len(section) + ) + section[end:end] = lines_added + out = lines[: bounds[0]] + [line for section in media for line in section] + return "\r\n".join(out) + "\r\n" + + +def _candidate_section( + mids: dict[str, int], count: int, candidate: IceCandidateLike +) -> int: + index: int | None + if candidate.sdp_mid is not None: + index = mids.get(candidate.sdp_mid) + if index is None or candidate.sdp_mline_index not in (None, index): + raise SdpError("ICE candidate does not match a media section") + return index + index = candidate.sdp_mline_index + if index is None and count == 1: + index = 0 + if index is None or not 0 <= index < count: + raise SdpError("ICE candidate does not match a media section") + return index + + +def _default_address(candidates: Sequence[str]) -> tuple[str, int]: + """Return the connection address of the first candidate (RFC 8839 4.2.1.2).""" + for candidate in candidates: + parts = candidate_value(candidate).split() + if len(parts) >= 6: + return parts[4], int(parts[5]) + return "0.0.0.0", 9 # noqa: S104 - SDP placeholder address, not a bind + + +def _connection_line(address: str) -> str: + family = "IP6" if ":" in address else "IP4" + return f"c=IN {family} {address}" + + +def _transport_lines(local: LocalParams, setup: str) -> list[str]: + lines = [f"a=candidate:{candidate_value(c)}" for c in local.candidates] + if local.candidates: + lines.append("a=end-of-candidates") + return [ + *lines, + f"a=ice-ufrag:{local.ice_ufrag}", + f"a=ice-pwd:{local.ice_pwd}", + f"a=fingerprint:sha-256 {local.fingerprint}", + f"a=setup:{setup}", + ] + + +def _ssrc_lines(local: LocalParams, ssrc: int, track: str) -> list[str]: + return [ + f"a=msid:{local.stream_id} {track}", + f"a=ssrc:{ssrc} cname:{local.cname}", + f"a=ssrc:{ssrc} msid:{local.stream_id} {track}", + ] + + +def rid_line(video: VideoParams) -> str: + """Return the RID restriction line for the video tier.""" + return ( + f"a=rid:{RID} send max-width={video.width};max-height={video.height};" + f"max-fps={video.fps};max-br={video.peak_bitrate_kbps * 1000}" + ) + + +def _video_section( + local: LocalParams, + video: VideoParams, + payload_type: int, + mid: str, + direction: str, + setup: str, +) -> list[str]: + address, port = _default_address(local.candidates) + pt = payload_type + return [ + f"m=video {port} UDP/TLS/RTP/SAVPF {pt}", + _connection_line(address), + *_transport_lines(local, setup), + f"a=mid:{mid}", + f"a={direction}", + "a=rtcp-mux", + "a=rtcp-rsize", + f"a=rtpmap:{pt} H265/{VIDEO_CLOCK_RATE}", + f"a=rtcp-fb:{pt} nack", + f"a=rtcp-fb:{pt} nack pli", + f"a=rtcp-fb:{pt} ccm fir", + rid_line(video), + *_ssrc_lines(local, local.video_ssrc, "video"), + ] + + +def _audio_section( + local: LocalParams, payload_type: int, mid: str, direction: str, setup: str +) -> list[str]: + address, port = _default_address(local.candidates) + pt = payload_type + lines = [ + f"m=audio {port} UDP/TLS/RTP/SAVPF {pt}", + _connection_line(address), + *_transport_lines(local, setup), + f"a=mid:{mid}", + f"a={direction}", + "a=rtcp-mux", + "a=rtcp-rsize", + f"a=rtpmap:{pt} opus/{AUDIO_CLOCK_RATE}/2", + f"a=fmtp:{pt} minptime=20;useinbandfec=1;stereo=0;sprop-stereo=0", + f"a=ptime:{OPUS_PTIME}", + ] + if local.audio_ssrc is not None and direction in ("sendonly", "sendrecv"): + lines += _ssrc_lines(local, local.audio_ssrc, "audio") + return lines + + +def _session_lines(local: LocalParams, mids: Sequence[str]) -> list[str]: + return [ + "v=0", + f"o=- {local.session_id} {local.session_version} IN IP4 127.0.0.1", + "s=-", + "t=0 0", + f"a=group:BUNDLE {' '.join(mids)}", + f"a=msid-semantic: WMS {local.stream_id}", + ] + + +def build_offer( + local: LocalParams, video: VideoParams, audio: AudioParams | None +) -> str: + """Build the accessory's offer.""" + mids = [VIDEO_MID] + ([AUDIO_MID] if audio else []) + lines = _session_lines(local, mids) + lines += _video_section( + local, video, video.payload_type, VIDEO_MID, "sendonly", "actpass" + ) + if audio: + direction = "sendrecv" if audio.talkback else "sendonly" + lines += _audio_section( + local, audio.payload_type, AUDIO_MID, direction, "actpass" + ) + return "\r\n".join(lines) + "\r\n" + + +_ANSWER_DIRECTION = { + # (offered direction, we may receive) -> answered direction + ("sendrecv", True): "sendrecv", + ("sendrecv", False): "sendonly", + ("recvonly", True): "sendonly", + ("recvonly", False): "sendonly", + ("sendonly", True): "recvonly", + ("sendonly", False): "inactive", + ("inactive", True): "inactive", + ("inactive", False): "inactive", +} + + +@dataclass(frozen=True) +class Answer: + """An answer to a controller's offer and the payload types it selected.""" + + sdp: str + video_payload_type: int | None + audio_payload_type: int | None + video_mid: str | None + audio_mid: str | None + audio_direction: str | None = None + + +def build_answer( + offer: SessionDescription, + local: LocalParams, + setup: str, + video: VideoParams, + audio: AudioParams | None, +) -> Answer: + """Answer a controller's re-offer with the established transport. + + The first H.265 video section and the first Opus audio section are + accepted, every other section is rejected with port 0. + """ + accepted: list[str] = [] + body: list[str] = [] + video_pt = audio_pt = None + video_mid = audio_mid = audio_direction = None + for index, media in enumerate(offer.media): + mid = media.mid if media.mid is not None else str(index) + pt = None + if media.kind == "video" and video_pt is None and media.port: + pt = media.payload_type_for("H265", VIDEO_CLOCK_RATE) + elif media.kind == "audio" and audio and audio_pt is None and media.port: + pt = media.payload_type_for("opus", AUDIO_CLOCK_RATE, 2) + if pt is None: + body += _rejected_section(media, mid) + continue + accepted.append(mid) + if media.kind == "video": + video_pt, video_mid = pt, mid + direction = _ANSWER_DIRECTION[(media.direction, False)] + body += _video_section(local, video, pt, mid, direction, setup) + else: + audio_pt, audio_mid = pt, mid + talkback = bool(audio and audio.talkback) + direction = audio_direction = _ANSWER_DIRECTION[(media.direction, talkback)] + body += _audio_section(local, pt, mid, direction, setup) + if video_pt is None: + raise SdpError("Offer has no H.265 video") + lines = _session_lines(local, accepted) + body + return Answer( + "\r\n".join(lines) + "\r\n", + video_pt, + audio_pt, + video_mid, + audio_mid, + audio_direction, + ) + + +def _rejected_section(media: MediaDescription, mid: str) -> list[str]: + formats = " ".join(media.formats) or "0" + return [ + f"m={media.kind} 0 {media.protocol} {formats}", + "c=IN IP4 0.0.0.0", + f"a=mid:{mid}", + "a=inactive", + ] diff --git a/tests/test_media_hevc.py b/tests/test_media_hevc.py new file mode 100644 index 00000000..d999e5f4 --- /dev/null +++ b/tests/test_media_hevc.py @@ -0,0 +1,316 @@ +"""Tests for pyhap.media.hevc.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +import pytest + +from pyhap.media.hevc import ( + AccessUnit, + HevcDepacketizer, + HevcPacketizer, + is_irap, + length_prefixed, + nal_type, + payload_starts_irap, + split_aggregation_packet, + split_length_prefixed, +) + +from .webrtc_helpers import blob, scrypted_input + + +def nal(kind: int, size: int, seed: str = "", layer: int = 0, tid: int = 1) -> bytes: + """Build a NAL unit of the given type and total size.""" + body = b"" + counter = 0 + while len(body) < size - 2: + body += hashlib.sha256(f"{seed}{kind}:{counter}".encode()).digest() + counter += 1 + header = bytes([kind << 1 | layer >> 5, (layer & 0x1F) << 3 | tid]) + return header + body[: size - 2] + + +VPS, SPS, PPS = nal(32, 24), nal(33, 40), nal(34, 8) +IDR = nal(19, 6000, "idr") +TRAIL = nal(1, 900, "trail") + + +def feed( + depacketizer: HevcDepacketizer, + units: list[list[bytes]], + sequence: int = 0, + skip: frozenset[int] = frozenset(), +) -> list[AccessUnit]: + """Send RTP payload lists (one per access unit) and collect the output.""" + out = [] + for index, payloads in enumerate(units): + for position, payload in enumerate(payloads): + if sequence not in skip: + unit = depacketizer.push( + sequence & 0xFFFF, + 3000 * (index + 1), + position == len(payloads) - 1, + payload, + ) + if unit is not None: + out.append(unit) + sequence += 1 + return out + + +def round_trip(nals: list[bytes], max_payload: int) -> list[list[bytes]]: + """Packetize and depacketize one access unit.""" + payloads = HevcPacketizer(max_payload).packetize(nals) + return nals_of(feed(HevcDepacketizer(), [payloads])) + + +def nals_of(units: list[AccessUnit]) -> list[list[bytes]]: + """Return the NAL units of each access unit.""" + return [unit.nals for unit in units] + + +def keyframes_of(units: list[AccessUnit]) -> list[bool]: + """Return whether each access unit is a keyframe.""" + return [unit.keyframe for unit in units] + + +def payload_types(nals: list[bytes], max_payload: int) -> list[int]: + """Return the RTP payload NAL types the packetizer produces.""" + return [nal_type(p) for p in HevcPacketizer(max_payload).packetize(nals)] + + +def max_payload_size(nals: list[bytes], max_payload: int) -> int: + """Return the largest RTP payload the packetizer produces.""" + return max(len(p) for p in HevcPacketizer(max_payload).packetize(nals)) + + +def input_description(case: dict[str, Any]) -> list[dict[str, Any]]: + """Describe the rebuilt RTP input the way the vector stores it.""" + return [ + {"sequence": seq, "timestamp": ts, "marker": marker, "payload": blob(data)} + for seq, ts, marker, data in scrypted_input(case) + ] + + +@pytest.mark.parametrize( + "nals", + [ + [TRAIL], + [VPS, SPS, PPS, IDR], + [nal(1, 100, "a"), nal(1, 200, "b"), nal(1, 300, "c")], + [VPS, SPS, PPS, nal(39, 30), IDR, nal(40, 12)], + [nal(19, 250_000, "big")], + [nal(1, 1200, "exact")], + [nal(1, 1201, "over")], + [nal(1, 5, "tiny", layer=3, tid=2)], + ], + ids=["single", "keyframe", "slices", "sei", "large", "exact", "over", "layer"], +) +def test_round_trip(nals: list[bytes]) -> None: + """Verify packetize then depacketize restores the access unit.""" + assert round_trip(nals, 1200) == [nals] + + +def test_packetizer_uses_ap_and_fu() -> None: + """Verify small NAL units aggregate and large ones fragment.""" + assert payload_types([VPS, SPS, PPS, IDR], 1200) == [48, 49, 49, 49, 49, 49, 49] + assert max_payload_size([VPS, SPS, PPS, IDR], 1200) == 1200 + + +def test_packetizer_single_nal_not_aggregated() -> None: + """Verify a lone NAL unit goes out as a single NAL unit packet.""" + assert payload_types([TRAIL], 1200) == [1] + + +def test_packetizer_ap_header_uses_lowest_layer_and_tid() -> None: + """Verify the AP header carries the lowest LayerId and TID.""" + payload = HevcPacketizer().packetize( + [nal(1, 10, layer=2, tid=3), nal(1, 10, layer=1, tid=2)] + )[0] + assert payload[:2] == bytes([48 << 1, 1 << 3 | 2]) + + +def test_packetizer_rejects_tiny_budget() -> None: + """Verify an unusable payload budget is rejected.""" + with pytest.raises(ValueError, match="too small"): + HevcPacketizer(3) + + +def test_parameter_sets_inserted_before_irap() -> None: + """Verify cached VPS/SPS/PPS precede an IRAP that arrived without them.""" + packetizer = HevcPacketizer() + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([TRAIL]), + packetizer.packetize([IDR]), + ], + ) + assert nals_of(units) == [[VPS, SPS, PPS, IDR], [TRAIL], [VPS, SPS, PPS, IDR]] + assert keyframes_of(units) == [True, False, True] + + +def test_updated_parameter_sets_are_used() -> None: + """Verify the latest parameter sets are the ones inserted.""" + packetizer = HevcPacketizer() + sps2 = nal(33, 44, "new") + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([sps2, TRAIL]), + packetizer.packetize([IDR]), + ], + ) + assert units[-1].nals == [VPS, sps2, PPS, IDR] + + +def test_irap_without_known_parameter_sets_passes() -> None: + """Verify an IRAP is forwarded even before parameter sets are known.""" + units = feed(HevcDepacketizer(), [HevcPacketizer().packetize([IDR])]) + assert units[0].nals == [IDR] + + +def test_sequence_gap_drops_only_damaged_unit() -> None: + """Verify a lost packet drops its access unit and the next one survives.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + units = feed( + depacketizer, + [packetizer.packetize([IDR]), packetizer.packetize([TRAIL])], + skip=frozenset({2}), + ) + assert nals_of(units) == [[TRAIL]] + assert depacketizer.dropped == 1 + + +def test_sequence_wraps() -> None: + """Verify sequence numbers wrap at 65535 without a false gap.""" + units = feed(HevcDepacketizer(), [HevcPacketizer().packetize([IDR])], 65533) + assert units[0].nals == [IDR] + + +def test_timestamp_change_without_marker_drops_unit() -> None: + """Verify an access unit cut short by a new timestamp is dropped.""" + depacketizer = HevcDepacketizer() + assert depacketizer.push(1, 100, False, TRAIL) is None + unit = depacketizer.push(2, 200, True, TRAIL) + assert unit == AccessUnit(200, [TRAIL]) + assert depacketizer.dropped == 1 + + +@pytest.mark.parametrize( + "payloads", + [ + [bytes([49 << 1, 1, 0x01]) + b"middle", bytes([49 << 1, 1, 0x41]) + b"end"], + [bytes([49 << 1, 1, 0x81]) + b"start"], + [bytes([49 << 1, 1, 0x81]) + b"a", bytes([49 << 1, 1, 0x81]) + b"b"], + [bytes([49 << 1, 1, 0x81]) + b"a", TRAIL], + [bytes([49 << 1, 1])], + [bytes([50 << 1, 1]) + b"paci"], + [b"\x02"], + [bytes([48 << 1, 1, 0, 9]) + b"short"], + [bytes([48 << 1, 1, 0])], + ], + ids=[ + "fu-no-start", + "fu-no-end", + "fu-restart", + "fu-interrupted", + "fu-short", + "paci", + "short", + "ap-overrun", + "ap-truncated-length", + ], +) +def test_malformed_unit_dropped(payloads: list[bytes]) -> None: + """Verify malformed RTP payloads drop the access unit.""" + depacketizer = HevcDepacketizer() + assert feed(depacketizer, [payloads]) == [] + assert depacketizer.dropped == 1 + + +def test_oversized_unit_dropped() -> None: + """Verify an access unit over the size bound is dropped.""" + huge = bytes([1 << 1, 1]) + bytes(17 * 1024 * 1024) + depacketizer = HevcDepacketizer() + assert feed(depacketizer, [HevcPacketizer(60000).packetize([huge])]) == [] + assert depacketizer.dropped == 1 + + +def test_length_prefixed_round_trip() -> None: + """Verify the hvcC-style layout splits back into NAL units.""" + data = length_prefixed([VPS, SPS, IDR]) + assert data[:4] == len(VPS).to_bytes(4, "big") + assert split_length_prefixed(data) == [VPS, SPS, IDR] + + +@pytest.mark.parametrize("data", [b"\x00\x00", b"\x00\x00\x00\x05abc"]) +def test_split_length_prefixed_truncated(data: bytes) -> None: + """Verify truncated length-prefixed data is rejected.""" + with pytest.raises(ValueError, match="Truncated"): + split_length_prefixed(data) + + +def test_split_aggregation_packet_empty() -> None: + """Verify an AP without NAL units is malformed.""" + assert split_aggregation_packet(bytes([48 << 1, 1])) is None + + +def test_reset_drops_unit_in_progress() -> None: + """Verify reset forgets a partial unit, the sequence and the sets.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + first = packetizer.packetize([VPS, SPS, PPS, IDR]) + feed(depacketizer, [first]) + depacketizer.push(len(first), 6000, False, first[0]) + depacketizer.reset() + units = feed(depacketizer, [packetizer.packetize([IDR])], sequence=900) + assert nals_of(units) == [[IDR]] + assert depacketizer.parameter_sets == {} + assert depacketizer.dropped == 0 + + +def test_restart_keeps_parameter_sets() -> None: + """Verify restart forgets a partial unit and the sequence, not the sets.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + first = packetizer.packetize([VPS, SPS, PPS, IDR]) + feed(depacketizer, [first]) + depacketizer.push(len(first), 6000, False, first[0]) + depacketizer.restart() + units = feed(depacketizer, [packetizer.packetize([IDR])], sequence=900) + assert nals_of(units) == [[VPS, SPS, PPS, IDR]] + assert depacketizer.dropped == 0 + + +@pytest.mark.parametrize( + ("payload", "irap"), + [ + (TRAIL, False), + (IDR[:50], True), + (HevcPacketizer().packetize([IDR])[0], True), + (HevcPacketizer().packetize([IDR])[1], False), + (HevcPacketizer().packetize([VPS, SPS, PPS, nal(19, 30)])[0], True), + (HevcPacketizer().packetize([VPS, SPS, PPS])[0], False), + (b"\x26", False), + ], + ids=["trail", "single", "fu-start", "fu-middle", "ap", "ap-no-irap", "short"], +) +def test_payload_starts_irap(payload: bytes, irap: bool) -> None: + """Verify IRAP detection from the RTP payload header alone.""" + assert payload_starts_irap(payload) is irap + + +@pytest.mark.parametrize( + ("kind", "irap"), + [(1, False), (16, True), (21, True), (23, True), (24, False), (32, False)], +) +def test_is_irap(kind: int, irap: bool) -> None: + """Verify the IRAP range is BLA_W_LP through RSV_IRAP_VCL23.""" + assert is_irap(nal(kind, 4)) is irap diff --git a/tests/test_media_rtp.py b/tests/test_media_rtp.py new file mode 100644 index 00000000..d7eb7262 --- /dev/null +++ b/tests/test_media_rtp.py @@ -0,0 +1,147 @@ +"""Tests for pyhap.media.rtp.""" + +from __future__ import annotations + +from operator import attrgetter +import struct + +import pytest + +from pyhap.media import rtp +from pyhap.media.rtp import RtpPacket + +PACKET = RtpPacket( + payload_type=100, + sequence=65535, + timestamp=0xFFFFFFFF, + ssrc=0x01020304, + marker=True, + payload=b"data", +) + + +def test_rtp_round_trip() -> None: + """Verify an RTP packet survives build and parse.""" + data = rtp.build_rtp(PACKET) + assert data[:2] == bytes([0x80, 0x80 | 100]) + assert rtp.parse_rtp(data) == PACKET + + +def test_rtp_parse_skips_csrc_extension_and_padding() -> None: + """Verify CSRCs, a header extension and padding are skipped.""" + header = struct.pack(">BBHII", 0xB1, 96, 7, 9000, 5) + csrc = struct.pack(">I", 1) + extension = struct.pack(">HHI", 0xBEDE, 1, 0) + data = header + csrc + extension + b"payload" + b"\x00\x00\x03" + assert rtp.parse_rtp(data) == RtpPacket(96, 7, 9000, 5, False, b"payload") + + +@pytest.mark.parametrize( + "data", + [ + b"\x80\x60", + bytes([0x40]) + bytes(11), + bytes([0x90]) + bytes(11), + bytes([0xA0]) + bytes(11) + b"\x00", + bytes([0xA0]) + bytes(11) + b"\x20", + bytes([0x8F]) + bytes(11), + ], + ids=["short", "version", "extension", "zero-padding", "padding", "csrc"], +) +def test_rtp_parse_rejects(data: bytes) -> None: + """Verify malformed RTP raises ValueError.""" + with pytest.raises(ValueError, match="RTP"): + rtp.parse_rtp(data) + + +def test_parse_pli() -> None: + """Verify a PLI names its media SSRC.""" + (message,) = rtp.parse_rtcp(rtp.build_pli(1, 2)) + assert message.is_keyframe_request + assert (message.sender_ssrc, message.media_ssrcs) == (1, [2]) + + +def test_parse_fir() -> None: + """Verify a FIR takes its targets from the FCI.""" + fir = struct.pack(">BBHIIIBxxx", 0x84, 206, 4, 1, 0, 77, 3) + (message,) = rtp.parse_rtcp(fir) + assert message.is_keyframe_request + assert message.media_ssrcs == [77] + + +def test_parse_nack_with_bitmask() -> None: + """Verify a NACK expands its lost-packet bitmask across the wrap.""" + nack = rtp.build_nack(1, 2, [65534])[:-2] + struct.pack(">H", 0b101) + (message,) = rtp.parse_rtcp(nack) + assert message.is_nack + assert message.lost == [65534, 65535, 1] + + +def test_parse_compound_sr_sdes_bye() -> None: + """Verify a compound SR, SDES and BYE parses part by part.""" + compound = ( + rtp.build_sender_report(9, rtp.ntp_time(0), 1, 2, 3) + + rtp.build_sdes(9, "cname") + + rtp.build_bye([9, 10]) + ) + messages = rtp.parse_rtcp(compound) + assert list(map(attrgetter("packet_type"), messages)) == [200, 202, 203] + assert messages[2].media_ssrcs == [9, 10] + + +def test_parse_rtcp_stops_at_garbage() -> None: + """Verify parsing stops at a part with a bad version or length.""" + assert rtp.parse_rtcp(rtp.build_pli(1, 2) + b"\x00" * 8) == rtp.parse_rtcp( + rtp.build_pli(1, 2) + ) + assert rtp.parse_rtcp(rtp.build_pli(1, 2)[:-1]) == [] + + +@pytest.mark.parametrize( + "data", + [ + bytes([0x81, 206, 0, 0]) + bytes(8), + bytes([0x80, 203, 0, 0, 0x80, 200, 0, 0]), + ], + ids=["zero-length-pli", "zero-length-bye-then-sr"], +) +def test_parse_rtcp_rejects_zero_length(data: bytes) -> None: + """Verify a part without room for the sender SSRC stops parsing.""" + assert rtp.parse_rtcp(data) == [] + + +@pytest.mark.parametrize( + ("payload_type", "rtcp"), [(96, False), (63, False), (64, True), (72, True)] +) +def test_rtp_packet_is_rtcp(payload_type: int, rtcp: bool) -> None: + """Verify RTCP sent to an RTP port is recognised by its payload type.""" + packet = rtp.RtpPacket(payload_type, 1, 2, 3, False, b"") + assert packet.is_rtcp is rtcp + + +def test_sender_report_layout() -> None: + """Verify the SR fields and length.""" + report = rtp.build_sender_report(9, 0x0102030405060708, 7, 8, 9) + assert len(report) == 28 + assert struct.unpack(">BBHIQIII", report) == ( + 0x80, + 200, + 6, + 9, + 0x0102030405060708, + 7, + 8, + 9, + ) + + +def test_sdes_is_word_aligned() -> None: + """Verify the SDES chunk is padded to 32 bits.""" + sdes = rtp.build_sdes(9, "abc") + assert len(sdes) % 4 == 0 + assert struct.unpack_from(">H", sdes, 2)[0] == len(sdes) // 4 - 1 + + +def test_ntp_time() -> None: + """Verify Unix time maps onto the NTP era.""" + assert rtp.ntp_time(1.5) == (rtp.NTP_EPOCH_OFFSET + 1) << 32 | 1 << 31 diff --git a/tests/test_media_sdp.py b/tests/test_media_sdp.py new file mode 100644 index 00000000..a4cd3205 --- /dev/null +++ b/tests/test_media_sdp.py @@ -0,0 +1,472 @@ +"""Tests for pyhap.media.sdp.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from dataclasses import dataclass +from functools import partial + +import pytest + +from pyhap.media import sdp +from pyhap.media.sdp import AudioParams, LocalParams, SdpError, VideoParams + +from .webrtc_helpers import IceCandidate + +FINGERPRINT = ":".join(["AB"] * 32) +LOCAL = LocalParams( + ice_ufrag="uf01", + ice_pwd="pw0123456789abcdefghij", + fingerprint=FINGERPRINT, + cname="cname01", + stream_id="stream01", + video_ssrc=11111, + audio_ssrc=22222, + candidates=( + "candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host", + "candidate:b2 1 udp 1694498815 203.0.113.7 61000 typ srflx raddr " + "192.168.1.20 rport 50000", + ), + session_id=42, +) +VIDEO = VideoParams(width=1920, height=1080, fps=30, peak_bitrate_kbps=2000) + +GOLDEN_OFFER = """v=0 +o=- 42 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +a=msid-semantic: WMS stream01 +m=video 50000 UDP/TLS/RTP/SAVPF 99 +c=IN IP4 192.168.1.20 +a=candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host +a=candidate:{srflx} +a=end-of-candidates +a=ice-ufrag:uf01 +a=ice-pwd:pw0123456789abcdefghij +a=fingerprint:sha-256 {fp} +a=setup:actpass +a=mid:0 +a=sendonly +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:99 H265/90000 +a=rtcp-fb:99 nack +a=rtcp-fb:99 nack pli +a=rtcp-fb:99 ccm fir +a=rid:1 send max-width=1920;max-height=1080;max-fps=30;max-br=2000000 +a=msid:stream01 video +a=ssrc:11111 cname:cname01 +a=ssrc:11111 msid:stream01 video +m=audio 50000 UDP/TLS/RTP/SAVPF 110 +c=IN IP4 192.168.1.20 +a=candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host +a=candidate:{srflx} +a=end-of-candidates +a=ice-ufrag:uf01 +a=ice-pwd:pw0123456789abcdefghij +a=fingerprint:sha-256 {fp} +a=setup:actpass +a=mid:1 +a=sendonly +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:110 opus/48000/2 +a=fmtp:110 minptime=20;useinbandfec=1;stereo=0;sprop-stereo=0 +a=ptime:20 +a=msid:stream01 audio +a=ssrc:22222 cname:cname01 +a=ssrc:22222 msid:stream01 audio +""" +GOLDEN_OFFER = ( + GOLDEN_OFFER.replace("{fp}", FINGERPRINT) + .replace("{srflx}", LOCAL.candidates[1].removeprefix("candidate:")) + .replace("\n", "\r\n") +) + +ANSWER = """v=0 +o=- 7 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +a=ice-ufrag:remU +a=ice-pwd:remotepassword0123456789 +a=fingerprint:sha-256 0a:0b:0c +m=video 9 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=setup:active +a=mid:0 +a=recvonly +a=rtcp-mux +a=rtpmap:100 H265/90000 +a=rtcp-fb:100 ccm fir +a=candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host +m=audio 9 UDP/TLS/RTP/SAVPF 111 +c=IN IP4 0.0.0.0 +a=setup:active +a=mid:1 +a=sendrecv +a=rtpmap:111 opus/48000/2 +a=ssrc:3333 cname:remote +a=ssrc:3333 msid:x y +a=end-of-candidates +""" + +REOFFER = """v=0 +o=- 9 3 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE v a d +m=video 9 UDP/TLS/RTP/SAVPF 96 98 +c=IN IP4 0.0.0.0 +a=ice-ufrag:remU +a=ice-pwd:remotepassword0123456789 +a=fingerprint:sha-256 0A:0B:0C +a=setup:actpass +a=mid:v +a=recvonly +a=rtpmap:96 H264/90000 +a=rtpmap:98 H265/90000 +m=audio 9 UDP/TLS/RTP/SAVPF 0 109 +c=IN IP4 0.0.0.0 +a=mid:a +a=sendrecv +a=rtpmap:0 PCMU/8000 +a=rtpmap:109 opus/48000/2 +m=application 9 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=mid:d +""" + + +def offer_with(**changes: object) -> str: + """Build an offer with modified parameters.""" + local = dataclasses.replace(LOCAL, **changes) # type: ignore[arg-type] + return sdp.build_offer(local, VIDEO, AudioParams()) + + +def directions(description: sdp.SessionDescription) -> list[str]: + """Return each section's direction.""" + return [media.direction for media in description.media] + + +def ports(description: sdp.SessionDescription) -> list[int]: + """Return each section's port.""" + return [media.port for media in description.media] + + +def test_golden_offer() -> None: + """Verify the full offer text.""" + assert sdp.build_offer(LOCAL, VIDEO, AudioParams()) == GOLDEN_OFFER + + +def test_offer_without_audio() -> None: + """Verify a video-only offer bundles one section.""" + parsed = sdp.parse(sdp.build_offer(LOCAL, VIDEO, None)) + assert parsed.bundle == ["0"] + assert ports(parsed) == [50000] + + +def test_offer_with_talkback() -> None: + """Verify talkback makes the audio section sendrecv.""" + offer = sdp.build_offer(LOCAL, VIDEO, AudioParams(talkback=True)) + assert directions(sdp.parse(offer)) == ["sendonly", "sendrecv"] + + +def test_offer_custom_payload_type() -> None: + """Verify the payload types are configurable.""" + video = VideoParams(1280, 720, 15, 800, payload_type=100) + offer = sdp.parse(sdp.build_offer(LOCAL, video, AudioParams(payload_type=111))) + assert offer.media[0].rtpmap == {100: ("H265", 90000, None)} + assert offer.media[1].rtpmap == {111: ("opus", 48000, 2)} + assert offer.media[0].rids == [ + "1 send max-width=1280;max-height=720;max-fps=15;max-br=800000" + ] + + +def test_offer_without_candidates_uses_placeholder() -> None: + """Verify the discard address is used when no candidate is known.""" + assert "m=video 9 UDP/TLS/RTP/SAVPF 99\r\nc=IN IP4 0.0.0.0\r\n" in offer_with( + candidates=() + ) + assert "a=end-of-candidates" not in offer_with(candidates=()) + + +def test_offer_ipv6_default_candidate() -> None: + """Verify an IPv6 default candidate gives an IP6 connection line.""" + offer = offer_with(candidates=("candidate:c 1 udp 1 fd00::5 4000 typ host",)) + assert "m=video 4000 UDP/TLS/RTP/SAVPF 99\r\nc=IN IP6 fd00::5\r\n" in offer + + +def test_parse_answer() -> None: + """Verify session-level attributes apply to every section.""" + answer = sdp.parse(ANSWER) + transport = answer.transport() + assert (transport.mid, transport.ice_ufrag, transport.ice_pwd) == ( + "0", + "remU", + "remotepassword0123456789", + ) + assert transport.fingerprints == [("sha-256", "0A:0B:0C")] + assert transport.setup == "active" + assert answer.candidates == ["1 1 udp 2122260223 10.0.0.2 52000 typ host"] + assert directions(answer) == ["recvonly", "sendrecv"] + assert list(answer.media[1].ssrcs) == [3333] + assert answer.media[1].payload_type_for("opus", 48000, 2) == 111 + + +def test_parse_media_level_overrides_session() -> None: + """Verify media-level ICE attributes win over session-level ones.""" + text = ANSWER.replace( + "a=setup:active\na=mid:0", "a=setup:active\na=ice-ufrag:own\na=mid:0" + ) + assert sdp.parse(text).media[0].ice_ufrag == "own" + assert sdp.parse(text).media[1].ice_ufrag == "remU" + + +def test_parse_ice_lite() -> None: + """Verify a=ice-lite is detected.""" + assert sdp.parse("a=ice-lite\r\n" + ANSWER).ice_lite + + +def test_transport_skips_rejected_section() -> None: + """Verify an unbundled answer uses the first accepted section.""" + text = ANSWER.replace("a=group:BUNDLE 0 1\n", "").replace("m=video 9", "m=video 0") + assert sdp.parse(text).transport().mid == "1" + + +def test_transport_requires_media() -> None: + """Verify an SDP without usable media is rejected.""" + with pytest.raises(SdpError, match="no usable"): + sdp.parse("v=0\r\n").transport() + + +@pytest.mark.parametrize( + "text", + [ + "m=video\r\n", + "m=video x UDP 96\r\n", + "m=video 9 UDP 96\r\na=rtpmap:x\r\n", + ], + ids=["short", "port", "rtpmap"], +) +def test_parse_rejects_malformed(text: str) -> None: + """Verify malformed lines raise SdpError.""" + with pytest.raises(SdpError): + sdp.parse(text) + + +@pytest.mark.parametrize( + ("candidate", "section"), + [ + (IceCandidate("candidate:9 1 udp 1 10.0.0.9 9 typ host", "1", 1), 1), + (IceCandidate("candidate:9 1 udp 1 10.0.0.9 9 typ host", "0"), 0), + (IceCandidate("a=candidate:9 1 udp 1 10.0.0.9 9 typ host", None, 1), 1), + ], + ids=["mid-and-index", "mid", "index"], +) +def test_merge_candidates(candidate: IceCandidate, section: int) -> None: + """Verify HAP candidates land in their section before end-of-candidates.""" + merged = sdp.parse(sdp.merge_candidates(ANSWER, [candidate])) + assert merged.media[section].candidates[-1] == "9 1 udp 1 10.0.0.9 9 typ host" + assert sdp.merge_candidates(ANSWER, [candidate]).endswith( + "a=candidate:9 1 udp 1 10.0.0.9 9 typ host\r\na=end-of-candidates\r\n" + ) is (section == 1) + + +def test_merge_candidates_single_section_without_mid() -> None: + """Verify a candidate without mid or index goes to the only section.""" + single = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 100\r\na=mid:0\r\n" + merged = sdp.merge_candidates( + single, [IceCandidate("candidate:1 1 udp 1 h 1 typ host")] + ) + assert sdp.parse(merged).candidates == ["1 1 udp 1 h 1 typ host"] + + +def test_merge_candidates_deduplicates() -> None: + """Verify a candidate already in the SDP is not added twice.""" + existing = IceCandidate("candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host", "0") + assert sdp.merge_candidates(ANSWER, [existing, IceCandidate("")]) == ANSWER.replace( + "\n", "\r\n" + ) + + +@pytest.mark.parametrize( + "candidate", + [ + IceCandidate("candidate:1 1 udp 1 h 1 typ host", "9"), + IceCandidate("candidate:1 1 udp 1 h 1 typ host", "0", 1), + IceCandidate("candidate:1 1 udp 1 h 1 typ host", None, 5), + IceCandidate("candidate:1 1 udp 1 h 1 typ host"), + IceCandidate("candidate:1 1 udp 1 h 1 typ host\r\na=x", "0"), + ], + ids=["unknown-mid", "mid-index-mismatch", "index-range", "ambiguous", "newline"], +) +def test_merge_candidates_rejects(candidate: IceCandidate) -> None: + """Verify candidates that match no section are rejected.""" + with pytest.raises(SdpError): + sdp.merge_candidates(ANSWER, [candidate]) + + +def test_merge_candidates_caps_each_section() -> None: + """Verify a section takes at most MAX_CANDIDATES candidates.""" + merged = sdp.parse(sdp.merge_candidates(ANSWER, many_candidates(40))) + assert len(merged.media[0].candidates) == sdp.MAX_CANDIDATES + assert merged.media[0].candidates[0] == "1 1 udp 2122260223 10.0.0.2 52000 typ host" + + +def test_merge_candidates_skips_present_before_matching() -> None: + """Verify a known candidate is skipped before its section is looked up.""" + known = IceCandidate("candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host", "9") + assert sdp.merge_candidates(ANSWER, [known]) == ANSWER.replace("\n", "\r\n") + + +def test_merge_candidates_stops_when_full() -> None: + """Verify candidates after the last free slot are not looked at.""" + unmatched = IceCandidate("candidate:x 1 udp 1 10.3.0.1 9 typ host", "9") + one_section = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 100\r\na=mid:0\r\n" + full = [*many_candidates(sdp.MAX_CANDIDATES), unmatched] + merged = sdp.parse(sdp.merge_candidates(one_section, full)) + assert len(merged.candidates) == sdp.MAX_CANDIDATES + + +@pytest.mark.parametrize( + "function", + [sdp.parse, partial(sdp.merge_candidates, candidates=[])], + ids=["parse", "merge"], +) +def test_rejects_oversized_sdp(function: Callable[[str], object]) -> None: + """Verify an SDP over MAX_SDP_SIZE is rejected before it is split.""" + with pytest.raises(SdpError, match="larger than"): + function(ANSWER + "a=x\n" * sdp.MAX_SDP_SIZE) + + +def test_parse_accepts_max_media_sections() -> None: + """Verify an SDP with MAX_MEDIA_SECTIONS media sections is parsed.""" + text = "v=0\r\n" + "m=audio 0 UDP 0\r\n" * sdp.MAX_MEDIA_SECTIONS + assert len(sdp.parse(text).media) == sdp.MAX_MEDIA_SECTIONS + + +def test_parse_rejects_too_many_media_sections() -> None: + """Verify an SDP with more than MAX_MEDIA_SECTIONS sections is rejected.""" + text = "v=0\r\n" + "m=audio 0 UDP 0\r\n" * (sdp.MAX_MEDIA_SECTIONS + 1) + with pytest.raises(SdpError, match="Too many media sections"): + sdp.parse(text) + + +def test_error_shows_a_short_line() -> None: + """Verify an error message shows at most 80 characters of the line.""" + with pytest.raises(SdpError) as err: + sdp.parse("m=video 9 UDP 96\r\na=rtpmap:" + "x" * 500 + "\r\n") + assert str(err.value) == f"Invalid attribute: {('a=rtpmap:' + 'x' * 71)!r}" + + +def test_parse_caps_candidates() -> None: + """Verify parsing keeps at most MAX_CANDIDATES per section.""" + text = ANSWER + "".join(candidate_lines(40)) + assert len(sdp.parse(text).media[1].candidates) == sdp.MAX_CANDIDATES + assert len(sdp.parse(text).candidates) == sdp.MAX_CANDIDATES + + +def test_video_params_pacing_bitrate() -> None: + """Verify pacing uses the target bit rate and falls back to the peak.""" + assert VIDEO.pacing_bitrate_kbps == 2000 + assert ( + dataclasses.replace(VIDEO, target_bitrate_kbps=900).pacing_bitrate_kbps == 900 + ) + + +def test_answer_to_reoffer() -> None: + """Verify a re-offer is answered with its payload types and mids.""" + answer = sdp.build_answer( + sdp.parse(REOFFER), LOCAL, "passive", VIDEO, AudioParams() + ) + parsed = sdp.parse(answer.sdp) + assert (answer.video_payload_type, answer.audio_payload_type) == (98, 109) + assert (answer.video_mid, answer.audio_mid) == ("v", "a") + assert answer.audio_direction == "sendonly" + assert parsed.bundle == ["v", "a"] + assert ports(parsed) == [50000, 50000, 0] + assert directions(parsed) == ["sendonly", "sendonly", "inactive"] + assert parsed.media[0].formats == ["98"] + assert parsed.media[0].setup == "passive" + assert parsed.media[0].rids == [sdp.rid_line(VIDEO)[len("a=rid:") :]] + + +def test_answer_to_reoffer_with_talkback() -> None: + """Verify talkback accepts the controller's audio.""" + answer = sdp.build_answer( + sdp.parse(REOFFER), LOCAL, "active", VIDEO, AudioParams(talkback=True) + ) + assert directions(sdp.parse(answer.sdp))[1] == "sendrecv" + assert answer.audio_direction == "sendrecv" + + +def test_answer_to_reoffer_without_audio() -> None: + """Verify audio is rejected when the sender has none.""" + answer = sdp.build_answer(sdp.parse(REOFFER), LOCAL, "active", VIDEO, None) + assert answer.audio_payload_type is None + assert answer.audio_direction is None + assert ports(sdp.parse(answer.sdp)) == [50000, 0, 0] + + +def test_answer_requires_h265() -> None: + """Verify a re-offer without H.265 cannot be answered.""" + with pytest.raises(SdpError, match="H.265"): + sdp.build_answer( + sdp.parse(REOFFER.replace("H265", "VP8")), LOCAL, "active", VIDEO, None + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("a=candidate:1 1 udp 1 h 1 typ host", "1 1 udp 1 h 1 typ host"), + ("candidate:1 1 udp 1 h 1 typ host", "1 1 udp 1 h 1 typ host"), + (" 1 1 udp 1 h 1 typ host ", "1 1 udp 1 h 1 typ host"), + ], +) +def test_candidate_value(value: str, expected: str) -> None: + """Verify candidate prefixes are stripped.""" + assert sdp.candidate_value(value) == expected + + +def many_candidates(count: int) -> list[IceCandidate]: + """Return ``count`` distinct HAP candidates for mid 0.""" + return [ + IceCandidate(f"candidate:{i} 1 udp 1 10.1.0.{i} 9 typ host", "0") + for i in range(count) + ] + + +def candidate_lines(count: int) -> list[str]: + """Return ``count`` distinct a=candidate lines.""" + return [f"a=candidate:{i} 1 udp 1 10.2.0.{i} 9 typ host\n" for i in range(count)] + + +def test_payload_types_ignore_non_ascii_digits() -> None: + """Verify formats like a superscript two are skipped, not a crash.""" + parsed = sdp.parse("v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 \u00b2\r\n") + assert parsed.media[0].payload_types == [96] + + +@dataclass(frozen=True) +class Tier: + """Stands in for pyhap.hksv3.VideoStreamTier: the fields used.""" + + width: int + height: int + frame_rate: int + target_bitrate: int + peak_bitrate: int | None = None + + +@pytest.mark.parametrize( + ("peak", "max_br"), [(3000, 3000), (None, 1200)], ids=["peak", "no-peak"] +) +def test_video_params_from_tier(peak: int | None, max_br: int) -> None: + """Verify a HAP tier maps to the offer, max-br from the peak bit rate.""" + video = VideoParams.from_tier(Tier(1280, 720, 24, 1200, peak)) + assert video == VideoParams(1280, 720, 24, max_br, 99, 1200) + assert sdp.rid_line(video).endswith(f"max-fps=24;max-br={max_br * 1000}") diff --git a/tests/webrtc_helpers.py b/tests/webrtc_helpers.py new file mode 100644 index 00000000..42869caa --- /dev/null +++ b/tests/webrtc_helpers.py @@ -0,0 +1,93 @@ +"""Shared helpers for the pyhap.media tests (not a test module).""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +import struct +from typing import Any + +from pyhap.media.hevc import NAL_AP, fragment + +DATA = Path(__file__).parent / "data" + + +@dataclass +class IceCandidate: + """Stands in for pyhap.hksv3.IceCandidate: the same fields.""" + + candidate: str + sdp_mid: str | None = None + sdp_mline_index: int | None = None + + +@dataclass +class SFrameKey: + """Stands in for pyhap.hksv3.SFrameKey: the same fields.""" + + key: bytes + kid: int + + +def load_vectors(name: str) -> Any: + """Load a JSON vector file from tests/data.""" + return json.loads((DATA / name).read_text()) + + +def seeded_bytes(seed: str, length: int) -> bytes: + """Reproduce the vector generator's SHA-256 counter byte stream.""" + out = b"" + counter = 0 + while len(out) < length: + out += hashlib.sha256(f"{seed}:{counter}".encode()).digest() + counter += 1 + return out[:length] + + +def plaintext(spec: dict[str, Any]) -> bytes: + """Regenerate a plaintext stored as seed and length.""" + return seeded_bytes(spec["seed"], spec["len"]) + + +def blob(value: bytes) -> Any: + """Describe bytes the way the generator stores them.""" + if len(value) <= 64: + return value.hex() + return {"len": len(value), "sha256": hashlib.sha256(value).hexdigest()} + + +def scrypted_nal(spec: list[Any]) -> bytes: + """Build a NAL unit from a generator ``[type, size, seed]`` spec.""" + kind, size, seed = spec + return bytes([kind << 1, 1]) + seeded_bytes(seed, size - 2) + + +def scrypted_payloads(unit: list[Any], fu_chunk: int) -> list[bytes]: + """Build the RTP payloads of one generator ``[kind, nal specs]`` entry.""" + kind, specs = unit + nals = [scrypted_nal(spec) for spec in specs] + if kind == "ap": + header = bytes([NAL_AP << 1, 1]) + return [header + b"".join(struct.pack(">H", len(n)) + n for n in nals)] + if kind == "fu": + return fragment(nals[0], fu_chunk + 3) + return nals + + +def scrypted_input(case: dict[str, Any]) -> list[tuple[int, int, bool, bytes]]: + """Rebuild the ``(sequence, timestamp, marker, payload)`` HEVC RTP input.""" + packets: list[tuple[int, int, bool, bytes]] = [] + sequence = int(case["input"][0]["sequence"]) + for index, unit in enumerate(case["access_units"]): + payloads = [ + payload + for spec in unit + for payload in scrypted_payloads(spec, case["fu_chunk"]) + ] + for position, payload in enumerate(payloads): + marker = position == len(payloads) - 1 + packets.append((sequence, 3000 * (index + 1), marker, payload)) + sequence = (sequence + 1) & 0xFFFF + return packets From 4baa3be0db3fcaeed7ced96d4b4d802c3a055712 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:13 +0100 Subject: [PATCH 35/56] Add a media source that reassembles a camera channel once A MediaSource binds the RTP input ports of one camera channel and reassembles its H.265 once for every session of the channel. --- pyhap/media/source.py | 294 +++++++++++++++++++++++++++++++++++++ tests/test_media_source.py | 273 ++++++++++++++++++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 pyhap/media/source.py create mode 100644 tests/test_media_source.py diff --git a/pyhap/media/source.py b/pyhap/media/source.py new file mode 100644 index 00000000..28ff7e5e --- /dev/null +++ b/pyhap/media/source.py @@ -0,0 +1,294 @@ +"""The RTP input of media sessions: one media source shared by many sessions. + +A :class:`MediaSource` binds two loopback UDP ports. The integrator points one +media pull of a camera channel at them (for example ffmpeg with ``-c:v copy +-bsf:v dump_extra -f rtp`` for H.265 and ``-c:a libopus -ac 1 -frame_duration +20 -f rtp`` for Opus). Any number of sessions attach to the source: the H.265 +access units are reassembled once per source, SFrame, RTP rewriting and SRTP +then run per session. + +Input is only reassembled while an attached session is connected; until then +the source only notes when the last IRAP picture started. RTCP that ffmpeg +sends to an RTP port is dropped, and a new input SSRC (a restarted source) +starts a fresh access unit. + +The source measures what its input carries: the video bit rate +(:attr:`MediaSource.video_bitrate`, which the sender's pacer follows) and the +interval between IRAP pictures (:attr:`MediaSource.gop`). Each port takes +datagrams from one sender address at a time; another sender is only accepted +once the first one was silent for :data:`INPUT_PEER_TIMEOUT` seconds, as a +restarted media pull is. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +import logging +import socket +import time +from typing import Protocol + +from . import rtp +from .hevc import AccessUnit, HevcDepacketizer, payload_starts_irap +from .sdp import VIDEO_CLOCK_RATE + +logger = logging.getLogger(__name__) + +INPUT_SOCKET_BUFFER = 4 * 1024 * 1024 +INPUT_PEER_TIMEOUT = 1.0 +# The bit rate is measured over windows of this many seconds and smoothed. +RATE_WINDOW = 1.0 +RATE_SMOOTHING = 0.5 +GOP_SMOOTHING = 0.25 +# IRAP intervals above this are not taken as the GOP of the input. +MAX_GOP = 60.0 + + +class MediaConsumer(Protocol): + """What a source needs from an attached session.""" + + @property + def wants_media(self) -> bool: + """Whether the session sends media now, so input must be reassembled.""" + + def on_source_video(self, unit: AccessUnit, ssrc: int) -> None: + """A complete access unit of the input SSRC.""" + + def on_source_audio(self, packet: rtp.RtpPacket) -> None: + """One Opus RTP packet.""" + + def on_source_closed(self, reason: str) -> None: + """The source ended; the session should end too.""" + + +class _InputProtocol(asyncio.DatagramProtocol): + """Hands datagrams of one sender address at a time to the callback.""" + + def __init__(self, callback: Callable[[bytes], None]) -> None: + self._callback = callback + self._peer: tuple[str | object, ...] | None = None + self._last = 0.0 + + def datagram_received(self, data: bytes, addr: tuple[str | object, ...]) -> None: + now = time.monotonic() + if addr != self._peer: + if self._peer is not None and now - self._last < INPUT_PEER_TIMEOUT: + return + logger.debug("Media input from %s", addr) + self._peer = addr + self._last = now + try: + self._callback(data) + except Exception: + logger.exception("Error handling media input") + + +def _input_socket(host: str) -> socket.socket: + family = socket.AF_INET6 if ":" in host else socket.AF_INET + sock = socket.socket(family, socket.SOCK_DGRAM) + try: + # A keyframe arrives as a burst; the default buffer drops part of it. + with contextlib.suppress(OSError): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, INPUT_SOCKET_BUFFER) + logger.debug( + "Media input buffer: %d bytes (asked for %d)", + sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF), + INPUT_SOCKET_BUFFER, + ) + sock.bind((host, 0)) + sock.setblocking(False) + except BaseException: + sock.close() + raise + return sock + + +class MediaSource: + """Plain RTP input on two UDP ports, fanned out to attached sessions.""" + + def __init__(self, host: str = "127.0.0.1", audio: bool = True) -> None: + """Configure the source; :meth:`start` binds the ports.""" + self.host = host + self.audio = audio + self.on_closed: Callable[[str], None] | None = None + self.last_video_input = 0.0 + self.video_bitrate = 0.0 + self.gop: float | None = None + self._last_keyframe: float | None = None + self._keyframe_timestamp: int | None = None + self._rate_start = 0.0 + self._rate_bytes = 0 + self._consumers: list[MediaConsumer] = [] + self._depacketizer = HevcDepacketizer() + self._video_ssrc: int | None = None + self._active = False + self._sockets: list[asyncio.DatagramTransport] = [] + self._ports = (0, 0) + self._started = False + self._closed = False + + @property + def rtp_input(self) -> tuple[str, int, int]: + """``(host, video_port, audio_port)``; the audio port is 0 without audio.""" + return self.host, self._ports[0], self._ports[1] + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self._ports[1] != 0 + + @property + def closed(self) -> bool: + """Whether the source was closed.""" + return self._closed + + @property + def session_count(self) -> int: + """The number of attached sessions.""" + return len(self._consumers) + + def _measure(self, packet: rtp.RtpPacket, now: float) -> None: + """Update the input bit rate and the IRAP interval.""" + self._rate_bytes += len(packet.payload) + elapsed = now - self._rate_start + if elapsed >= RATE_WINDOW: + rate = self._rate_bytes * 8 / elapsed if self._rate_start else 0.0 + self.video_bitrate = ( + rate + if not self.video_bitrate + else self.video_bitrate + RATE_SMOOTHING * (rate - self.video_bitrate) + ) + self._rate_start, self._rate_bytes = now, 0 + if not payload_starts_irap(packet.payload): + return + self._last_keyframe = now + previous, self._keyframe_timestamp = self._keyframe_timestamp, packet.timestamp + if previous is None or previous == packet.timestamp: + return + interval = ((packet.timestamp - previous) & 0xFFFFFFFF) / VIDEO_CLOCK_RATE + if interval > MAX_GOP: + return + self.gop = ( + interval + if self.gop is None + else self.gop + GOP_SMOOTHING * (interval - self.gop) + ) + + def keyframe_age(self) -> float | None: + """Seconds since the last IRAP picture started, None if none yet.""" + if self._last_keyframe is None: + return None + return time.monotonic() - self._last_keyframe + + async def start(self) -> tuple[str, int, int]: + """Bind the input ports and return :attr:`rtp_input`.""" + if self._closed: + raise RuntimeError("Media source is closed") + if self._started: + return self.rtp_input + self._started = True + try: + video = await self._bind(self._on_video_input) + audio = await self._bind(self._on_audio_input) if self.audio else 0 + except BaseException: + self._close_sockets() + raise + if self._closed: + self._close_sockets() + raise RuntimeError("Media source is closed") + self._ports = (video, audio) + return self.rtp_input + + async def _bind(self, callback: Callable[[bytes], None]) -> int: + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint( + lambda: _InputProtocol(callback), sock=_input_socket(self.host) + ) + self._sockets.append(transport) + port: int = transport.get_extra_info("sockname")[1] + return port + + def attach(self, consumer: MediaConsumer) -> None: + """Start delivering media to a session.""" + if self._closed: + raise RuntimeError("Media source is closed") + if consumer not in self._consumers: + self._consumers.append(consumer) + + def detach(self, consumer: MediaConsumer) -> None: + """Stop delivering media to a session.""" + with contextlib.suppress(ValueError): + self._consumers.remove(consumer) + + def close(self, reason: str = "closed") -> None: + """Release the ports and end every attached session with ``reason``.""" + if self._closed: + return + self._closed = True + self._close_sockets() + consumers, self._consumers = self._consumers, [] + for consumer in consumers: + try: + consumer.on_source_closed(reason) + except Exception: + logger.exception("Error ending a session of a media source") + if self.on_closed: + try: + self.on_closed(reason) + except Exception: + logger.exception("Error in media source close callback") + + def _close_sockets(self) -> None: + for sock in self._sockets: + sock.close() + self._sockets.clear() + + def _receivers(self) -> list[MediaConsumer]: + return [c for c in self._consumers if c.wants_media] + + def _on_video_input(self, data: bytes) -> None: + try: + packet = rtp.parse_rtp(data) + except ValueError: + return + if packet.is_rtcp: + return + now = time.monotonic() + self.last_video_input = now + if packet.ssrc != self._video_ssrc: + self._video_ssrc = packet.ssrc + self._keyframe_timestamp = None + self._depacketizer.reset() + self._measure(packet, now) + receivers = self._receivers() + if not receivers: + self._active = False + return + if not self._active: + self._active = True + self._depacketizer.restart() + unit = self._depacketizer.push( + packet.sequence, packet.timestamp, packet.marker, packet.payload + ) + if unit is None: + return + for receiver in receivers: + try: + receiver.on_source_video(unit, packet.ssrc) + except Exception: + logger.exception("Error sending video to a session") + + def _on_audio_input(self, data: bytes) -> None: + try: + packet = rtp.parse_rtp(data) + except ValueError: + return + if packet.is_rtcp or not packet.payload: + return + for receiver in self._receivers(): + try: + receiver.on_source_audio(packet) + except Exception: + logger.exception("Error sending audio to a session") diff --git a/tests/test_media_source.py b/tests/test_media_source.py new file mode 100644 index 00000000..0a9088c1 --- /dev/null +++ b/tests/test_media_source.py @@ -0,0 +1,273 @@ +"""Tests for pyhap.media.source: the shared RTP input of WebRTC sessions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +import socket +from types import SimpleNamespace + +import pytest + +from pyhap.media import rtp, source as source_module +from pyhap.media.hevc import AccessUnit, HevcPacketizer +from pyhap.media.source import MediaSource + +VPS, SPS, PPS = bytes([64, 1, 1]), bytes([66, 1, 2]), bytes([68, 1, 3]) +IDR = bytes([19 << 1, 1]) + bytes(3000) +TRAIL = bytes([1 << 1, 1]) + bytes(500) + + +class Consumer: + """Records what a source delivers to one session.""" + + def __init__(self, wants_media: bool = True) -> None: + """Start empty.""" + self.wants_media = wants_media + self.video: list[tuple[AccessUnit, int]] = [] + self.audio: list[rtp.RtpPacket] = [] + self.closed: list[str] = [] + + def on_source_video(self, unit: AccessUnit, ssrc: int) -> None: + """Record an access unit.""" + self.video.append((unit, ssrc)) + + def on_source_audio(self, packet: rtp.RtpPacket) -> None: + """Record an audio packet.""" + self.audio.append(packet) + + def on_source_closed(self, reason: str) -> None: + """Record the end of the source.""" + self.closed.append(reason) + + +class Feeder: + """Builds RTP for the source's input handlers.""" + + def __init__(self, ssrc: int = 1, sequence: int = 100) -> None: + """Start a stream.""" + self.ssrc = ssrc + self.sequence = sequence + self.timestamp = 0 + + def unit(self, nals: list[bytes]) -> list[bytes]: + """Return the RTP packets of one access unit.""" + payloads = HevcPacketizer(1200).packetize(nals) + packets = [] + for index, payload in enumerate(payloads): + last = index == len(payloads) - 1 + packet = rtp.RtpPacket( + 96, self.sequence, self.timestamp, self.ssrc, last, payload + ) + packets.append(rtp.build_rtp(packet)) + self.sequence = (self.sequence + 1) & 0xFFFF + self.timestamp += 3000 + return packets + + +def push(source: MediaSource, packets: list[bytes]) -> None: + """Hand datagrams to the video input.""" + for packet in packets: + source._on_video_input(packet) + + +def units_of(consumer: Consumer) -> list[list[bytes]]: + """Return the NAL units of each delivered access unit.""" + return [unit.nals for unit, _ssrc in consumer.video] + + +@pytest.fixture +async def source() -> AsyncIterator[MediaSource]: + """Yield a started source.""" + started = MediaSource() + await started.start() + yield started + started.close() + + +async def test_fan_out_reassembles_once(source: MediaSource) -> None: + """Verify every attached session gets the same access unit object.""" + first, second = Consumer(), Consumer() + source.attach(first) + source.attach(second) + push(source, Feeder().unit([VPS, SPS, PPS, IDR])) + assert units_of(first) == [[VPS, SPS, PPS, IDR]] + assert first.video[0][0] is second.video[0][0] + assert source.session_count == 2 + + +async def test_no_reassembly_without_a_connected_session(source: MediaSource) -> None: + """Verify input is not reassembled while no session sends, keyframes noted.""" + waiting = Consumer(wants_media=False) + source.attach(waiting) + assert source.keyframe_age() is None + push(source, Feeder().unit([VPS, SPS, PPS, IDR])) + assert waiting.video == [] + assert source.keyframe_age() is not None + assert source.last_video_input > 0 + + +async def test_rtcp_on_rtp_port_is_dropped(source: MediaSource) -> None: + """Verify an RTCP sender report sent to the video port is ignored.""" + consumer = Consumer() + source.attach(consumer) + report = rtp.build_sender_report(1, 0, 0, 0, 0) + source._on_video_input(report) + source._on_audio_input(report) + source._on_video_input(b"") + assert source.last_video_input == 0 + assert consumer.audio == [] + + +async def test_ssrc_change_starts_a_fresh_unit(source: MediaSource) -> None: + """Verify a restarted input (new SSRC) does not merge into the old unit.""" + consumer = Consumer() + source.attach(consumer) + old = Feeder(ssrc=1).unit([IDR]) + push(source, old[:1]) + push(source, Feeder(ssrc=2, sequence=old[0][3] + 1).unit([TRAIL])) + assert units_of(consumer) == [[TRAIL]] + assert consumer.video[0][1] == 2 + + +async def test_pause_keeps_parameter_sets(source: MediaSource) -> None: + """Verify a pause without receivers keeps the cached parameter sets.""" + consumer = Consumer() + source.attach(consumer) + feeder = Feeder() + push(source, feeder.unit([VPS, SPS, PPS, IDR])) + consumer.wants_media = False + push(source, feeder.unit([TRAIL])) + consumer.wants_media = True + push(source, feeder.unit([IDR])) + assert units_of(consumer) == [[VPS, SPS, PPS, IDR], [VPS, SPS, PPS, IDR]] + + +async def test_audio_is_forwarded(source: MediaSource) -> None: + """Verify Opus packets reach connected sessions only.""" + connected, waiting = Consumer(), Consumer(wants_media=False) + source.attach(connected) + source.attach(waiting) + packet = rtp.RtpPacket(111, 1, 960, 7, False, b"opus") + source._on_audio_input(rtp.build_rtp(packet)) + assert connected.audio == [packet] + assert waiting.audio == [] + + +async def test_close_ends_sessions(source: MediaSource) -> None: + """Verify closing the source ends attached sessions with the reason.""" + consumer = Consumer() + reasons: list[str] = [] + source.on_closed = reasons.append + source.attach(consumer) + source.detach(Consumer()) + source.close("ffmpeg exited") + source.close("again") + assert consumer.closed == ["ffmpeg exited"] + assert reasons == ["ffmpeg exited"] + assert source.session_count == 0 + with pytest.raises(RuntimeError, match="closed"): + source.attach(consumer) + with pytest.raises(RuntimeError, match="closed"): + await source.start() + + +async def test_input_ports_and_buffer(source: MediaSource) -> None: + """Verify both ports are bound with an enlarged receive buffer.""" + host, video, audio = source.rtp_input + assert host == "127.0.0.1" + assert video > 0 + assert audio > 0 + assert await source.start() == source.rtp_input + sock = source._sockets[0].get_extra_info("socket") + assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) >= 400_000 + + +async def test_video_only_source() -> None: + """Verify a source without audio binds one port.""" + source = MediaSource(audio=False) + assert (await source.start())[2] == 0 + source.close() + + +async def test_datagrams_reach_the_source(source: MediaSource) -> None: + """Verify real UDP input arrives through the bound socket.""" + consumer = Consumer() + source.attach(consumer) + sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + send_all(sender, source.rtp_input[1], Feeder().unit([VPS, SPS, PPS, IDR])) + await asyncio.wait_for(wait_for_video(consumer), 2) + sender.close() + assert units_of(consumer) == [[VPS, SPS, PPS, IDR]] + + +def send_all(sock: socket.socket, port: int, packets: list[bytes]) -> None: + """Send datagrams to a loopback port.""" + for packet in packets: + sock.sendto(packet, ("127.0.0.1", port)) + + +async def wait_for_video(consumer: Consumer) -> None: + """Wait until an access unit was delivered.""" + while not consumer.video: + await asyncio.sleep(0.01) + + +def feed_gop(source: MediaSource, feeder: Feeder, frames: int) -> None: + """Push one IRAP picture and ``frames - 1`` trailing pictures.""" + push(source, feeder.unit([VPS, SPS, PPS, IDR])) + for _ in range(frames - 1): + push(source, feeder.unit([TRAIL])) + + +async def test_measures_the_irap_interval(source: MediaSource) -> None: + """Verify the GOP comes from the RTP time between IRAP pictures.""" + feeder = Feeder() + feed_gop(source, feeder, 60) + assert source.gop is None + feed_gop(source, feeder, 30) + assert source.gop == 2.0 + push(source, feeder.unit([IDR])) + assert source.gop == 1.75 + + +async def test_irap_interval_restarts_with_a_new_ssrc(source: MediaSource) -> None: + """Verify a restarted input does not count the gap as a GOP.""" + feed_gop(source, Feeder(ssrc=1), 1) + feed_gop(source, Feeder(ssrc=2), 1) + assert source.gop is None + + +def measure(source: MediaSource, size: int, now: float) -> None: + """Measure one video packet of ``size`` payload bytes at ``now``.""" + source._measure(rtp.RtpPacket(96, 1, 0, 1, False, bytes([2, 1]) + bytes(size)), now) + + +async def test_measures_the_video_bitrate(source: MediaSource) -> None: + """Verify the bit rate is measured per window and smoothed.""" + measure(source, 98, 100.0) + measure(source, 998, 100.5) + measure(source, 998, 101.0) + assert source.video_bitrate == 16000 + measure(source, 3998, 102.0) + assert source.video_bitrate == 24000 + + +def pinned_protocol() -> tuple[source_module._InputProtocol, list[bytes]]: + """Return an input protocol and the datagrams it passes on.""" + received: list[bytes] = [] + return source_module._InputProtocol(received.append), received + + +def test_input_is_pinned_to_one_sender(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify a second sender is only taken after the first went silent.""" + clock = iter([10.0, 10.5, 10.9, 12.0, 12.1]) + fake_time = SimpleNamespace(monotonic=lambda: next(clock)) + monkeypatch.setattr(source_module, "time", fake_time) + protocol, received = pinned_protocol() + protocol.datagram_received(b"a1", ("127.0.0.1", 1000)) + protocol.datagram_received(b"b1", ("127.0.0.1", 2000)) + protocol.datagram_received(b"a2", ("127.0.0.1", 1000)) + protocol.datagram_received(b"b2", ("127.0.0.1", 2000)) + protocol.datagram_received(b"a3", ("127.0.0.1", 1000)) + assert received == [b"a1", b"a2", b"b2"] From 0478a0c887464d3183098d5878811d125d5ff73e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:13 +0100 Subject: [PATCH 36/56] Share one source and pull per camera channel SourcePool leases the source of a channel to each session; all sessions of a channel share one source and one pull. FFmpegSourceProvider is a reference provider that pulls each channel with one ffmpeg, restarts it with a backoff and redacts URLs in its log. --- pyhap/media/ffmpeg.py | 542 ++++++++++++++++++++++++++++ pyhap/media/pool.py | 315 ++++++++++++++++ tests/test_media_ffmpeg_provider.py | 408 +++++++++++++++++++++ tests/webrtc_fakes.py | 229 ++++++++++++ 4 files changed, 1494 insertions(+) create mode 100644 pyhap/media/ffmpeg.py create mode 100644 pyhap/media/pool.py create mode 100644 tests/test_media_ffmpeg_provider.py create mode 100644 tests/webrtc_fakes.py diff --git a/pyhap/media/ffmpeg.py b/pyhap/media/ffmpeg.py new file mode 100644 index 00000000..f66fd136 --- /dev/null +++ b/pyhap/media/ffmpeg.py @@ -0,0 +1,542 @@ +"""A reference source provider that pulls each camera channel with ffmpeg. + +:class:`FFmpegSourceProvider` is a ``source_provider`` for the media +delegates of a camera's sessions: it resolves the tier of a session to +ffmpeg input arguments and leases that channel's source from its +:class:`~pyhap.media.pool.SourcePool`, with an :class:`FFmpegPull` as the +channel's pull. All sessions of a channel share one ffmpeg. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping, Sequence +import contextlib +from dataclasses import dataclass, replace +from functools import partial +import logging +import re +import time +from typing import TYPE_CHECKING, Any + +from pyhap import hksv3 +from pyhap.camera_stream import StreamError + +from .pool import ChannelLease, SourcePool + +if TYPE_CHECKING: + from pyhap.camera_stream import StreamSession + + from .worker import MediaWorker + +logger = logging.getLogger(__name__) + +_OFFER_ERROR = hksv3.WebRTCSolicitOfferStatus.ERROR +# Seconds ffmpeg gets to exit after SIGTERM before it is killed. +FFMPEG_STOP_TIMEOUT = 3.0 +# Userinfo may hold spaces and quotes, so it is matched up to its "@" first. +_URL = re.compile( + r"(?P[A-Za-z][A-Za-z0-9+.-]*://)(?:[^/?#@\n]*@)?(?P[^\s'\"]+)" +) +# Bytes of one stderr line kept before it is logged anyway. +MAX_LOG_LINE = 4096 +# Restarts after failures back off up to this many seconds. +MAX_RESTART_DELAY = 30.0 +# An ffmpeg that ran this long counts as healthy again. +HEALTHY_RUN = 30.0 + + +def redact_urls(text: str) -> str: + """Keep only scheme, host and port of the URLs in ``text``. + + Credentials, path and query of a camera URL may carry secrets, such as + an access token in the path. + """ + return _URL.sub(_redact_url, text) + + +def _redact_url(match: re.Match[str]) -> str: + rest = match["rest"] + authority = re.split(r"[/?#]", rest, maxsplit=1)[0] + host = authority.rpartition("@")[2] + hidden = "/" if len(authority) < len(rest) else "" + return f"{match['scheme']}{host}{hidden}" + + +@dataclass(frozen=True) +class FFmpegInput: + """The ffmpeg input of a camera channel. + + ``args`` are ffmpeg's input arguments, such as ``["-rtsp_transport", + "tcp", "-i", url]``. The other fields override the provider's defaults + when set: ``video_map`` and ``audio_map`` select the streams, ``audio`` + turns audio off for inputs without it, and ``gop`` is the expected + interval between IRAP pictures until the source has measured it. + """ + + args: tuple[str, ...] + video_map: str | None = None + audio_map: str | None = None + audio: bool | None = None + gop: float | None = None + + +InputResolver = Callable[ + [hksv3.VideoStreamTier], Awaitable["Sequence[str] | FFmpegInput | None"] +] + + +class FFmpegSourceProvider: + """A ``source_provider`` that pulls each channel with ffmpeg. + + ``inputs`` maps a tier identifier, or a :class:`pyhap.hksv3.VideoQuality` + (also by name, e.g. ``"medium"``), to ffmpeg input arguments or an + :class:`FFmpegInput`; an identifier wins over a quality. It can instead + be an async callable that resolves a tier to the same, or None when + nothing fits. Tiers with the same input share one ffmpeg. + + Video ``video_map`` is copied with ``dump_extra`` so every IRAP picture + carries its parameter sets (an Annex B input such as RTSP; MP4 needs + ``hevc_mp4toannexb``); ``audio_map``, unless ``audio`` is false, is + encoded to mono Opus. ffmpeg fails when a mapped stream is missing, so + set ``audio=False`` for inputs without audio. The Opus bit rate and frame + duration follow the audio tier of the session that opens the channel; a + camera has one audio tier for its multi-tier and WebRTC sessions, so all + sessions of a channel get the same audio. The command is an argument + list that no shell sees; the URLs in ffmpeg's log are reduced to scheme, + host and port. + + ffmpeg is restarted when it exits while sessions need it (after + ``restart_delay``), and for a keyframe request only when no IRAP picture + came for twice the GOP and ffmpeg runs at least that long: a copied + stream gets its next IRAP picture with the next GOP anyway. The GOP is + the one the source measures, until then ``gop``. + """ + + def __init__( + self, + inputs: Mapping[int | hksv3.VideoQuality | str, Sequence[str] | FFmpegInput] + | InputResolver, + *, + worker: MediaWorker | None = None, + ffmpeg: str = "ffmpeg", + video_map: str = "0:v:0", + audio_map: str = "0:a:0", + audio: bool = True, + gop: float = 2.0, + restart_delay: float = 1.0, + stats_period: float | None = None, + host: str = "127.0.0.1", + on_error: Callable[[str, str], None] | None = None, + ) -> None: + """Configure the inputs; nothing runs before the first answer. + + ``on_error(channel, message)`` hears each unexpected ffmpeg exit or + failed start, e.g. to mark the camera unavailable. + """ + self._resolver: InputResolver | None = None + self._by_id: dict[int, FFmpegInput] = {} + self._by_quality: dict[int, FFmpegInput] = {} + if not isinstance(inputs, Mapping): + self._resolver = inputs + else: + for key, value in inputs.items(): + if isinstance(key, str): + self._by_quality[hksv3.VideoQuality[key.upper()]] = _input(value) + elif isinstance(key, hksv3.VideoQuality): + self._by_quality[key] = _input(value) + else: + self._by_id[key] = _input(value) + self.pool = SourcePool(worker, host) + self.ffmpeg = ffmpeg + self.video_map = video_map + self.audio_map = audio_map + self.audio = audio + self.gop = gop + self.restart_delay = restart_delay + self.stats_period = stats_period + self.on_error = on_error + self._pulls: dict[str, FFmpegPull] = {} + + async def input_for(self, tier: hksv3.VideoStreamTier) -> FFmpegInput: + """The input of a tier, with the provider's defaults filled in. + + :raises StreamError: If no input fits the tier. + """ + if self._resolver is not None: + value = await self._resolver(tier) + found = None if value is None else _input(value) + else: + found = self._by_id.get(tier.identifier) or self._by_quality.get( + tier.quality + ) + if found is None: + raise StreamError(_OFFER_ERROR, f"No ffmpeg input for tier {tier}") + return FFmpegInput( + found.args, + found.video_map or self.video_map, + found.audio_map or self.audio_map, + self.audio if found.audio is None else found.audio, + found.gop or self.gop, + ) + + def channel_name(self, tier: hksv3.VideoStreamTier) -> str: + """How the log names the channel of a tier, without its input.""" + if tier.identifier in self._by_id: + return f"tier {tier.identifier}" + with contextlib.suppress(ValueError): + return hksv3.VideoQuality(tier.quality).name.lower() + return f"tier {tier.identifier}" + + def command( + self, + ffmpeg_input: FFmpegInput, + rtp_input: tuple[str, int, int], + audio_bitrate: int, + packet_time: int = 20, + ) -> list[str]: + """The ffmpeg arguments sending one input to a source's ports.""" + host, video_port, audio_port = rtp_input + host = f"[{host}]" if ":" in host else host + stats = ( + ["-stats", "-stats_period", str(self.stats_period)] + if self.stats_period + else ["-nostats"] + ) + video_map = ffmpeg_input.video_map or self.video_map + command = [ + *(self.ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "warning"), + *stats, + *ffmpeg_input.args, + *("-map", video_map, "-c:v", "copy", "-bsf:v", "dump_extra"), + *("-payload_type", str(hksv3.VIDEO_PAYLOAD_TYPE)), + # The source drops RTCP, and port + 1 may be the audio input. + *("-rtpflags", "skip_rtcp", "-f", "rtp", f"rtp://{host}:{video_port}"), + ] + if audio_port: + audio_map = ffmpeg_input.audio_map or self.audio_map + command += [ + *("-map", audio_map, "-c:a", "libopus", "-ac", "1", "-ar", "48000"), + *("-b:a", str(audio_bitrate), "-application", "lowdelay"), + *("-frame_duration", str(packet_time)), + *("-payload_type", str(hksv3.AUDIO_PAYLOAD_TYPE)), + *("-rtpflags", "skip_rtcp", "-f", "rtp", f"rtp://{host}:{audio_port}"), + ] + return command + + async def __call__( + self, + session: StreamSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> ChannelLease: + """Lease the source of the session's channel. + + ``audio_tier`` is None for a multi-tier session without audio; the + channel's audio then follows :data:`pyhap.hksv3.DEFAULT_AUDIO_TIER`. + """ + return await self.lease_channel(video_tier, audio_tier) + + async def lease_channel( + self, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None = None, + ) -> ChannelLease: + """Lease the source of a tier's channel without a session. + + For example to keep a channel buffered between sessions. + """ + found = await self.input_for(video_tier) + name = self.channel_name(video_tier) + audio = audio_tier or hksv3.DEFAULT_AUDIO_TIER + command = partial( + self.command, + found, + audio_bitrate=audio.target_bitrate, + packet_time=audio.packet_time, + ) + gop = found.gop or self.gop + + def make_pull() -> FFmpegPull: + pull = FFmpegPull( + command, name, gop, self.restart_delay, on_error=self._pull_error + ) + self._pulls[name] = pull + return pull + + return await self.pool.lease( + replace(found, gop=None), + make_pull, + audio=bool(found.audio), + name=name, + ) + + def _pull_error(self, channel: str, message: str) -> None: + if self.on_error is not None: + try: + self.on_error(channel, message) + except Exception: + logger.exception("Error in the ffmpeg error callback") + + def diagnostics(self) -> dict[str, Any]: + """Leases and the ffmpeg of each channel, for an integration's diagnostics.""" + counts = self.pool.counts() + worker = self.pool.worker + return { + "worker_pid": worker.pid if worker is not None else None, + "channels": { + name: { + "leases": leases, + "active": active, + **(self._pulls[name].diagnostics() if name in self._pulls else {}), + } + for name, (leases, active) in counts.items() + }, + } + + async def close(self) -> None: + """Stop every ffmpeg and close every source.""" + await self.pool.close() + + +def _input(value: Sequence[str] | FFmpegInput) -> FFmpegInput: + if isinstance(value, FFmpegInput): + return replace(value, args=tuple(value.args)) + return FFmpegInput(tuple(value)) + + +class FFmpegPull: + """One ffmpeg process feeding a channel's source, see ``ChannelPull``.""" + + def __init__( + self, + command: Callable[[tuple[str, int, int]], list[str]], + name: str, + gop: float, + restart_delay: float, + on_error: Callable[[str, str], None] | None = None, + ) -> None: + """Configure the pull; :meth:`start` runs ffmpeg. + + ``gop`` is the expected IRAP interval until the source measured one. + ``on_error(name, message)`` hears each unexpected exit or failed start. + """ + self.name = name + self.gop = gop + self.restart_delay = restart_delay + self.on_error = on_error + self.restarts = 0 + self.last_error: str | None = None + self._failures = 0 + self._command = command + self._argv: list[str] = [] + self._process: asyncio.subprocess.Process | None = None + self._watcher: asyncio.Task[None] | None = None + self._started = 0.0 + self._running = False + self._restart_now = asyncio.Event() + + @property + def pid(self) -> int | None: + """The process ID of the running ffmpeg.""" + process = self._process + return process.pid if process and process.returncode is None else None + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Run ffmpeg and keep it running until :meth:`stop`.""" + self._argv = self._command(rtp_input) + self._running = True + try: + await self._spawn() + except BaseException: + self._running = False + raise + self._watcher = asyncio.ensure_future(self._watch()) + + async def _spawn(self) -> None: + self._restart_now.clear() + self._process = await asyncio.create_subprocess_exec( + *self._argv, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + self._started = time.monotonic() + logger.info("ffmpeg for %s started, pid %d", self.name, self._process.pid) + + def diagnostics(self) -> dict[str, Any]: + """The pid, restarts and last error, for diagnostics.""" + return { + "pid": self.pid, + "restarts": self.restarts, + "last_error": self.last_error, + } + + async def _watch(self) -> None: + while self._running and self._process is not None: + process = self._process + last_line = await _log_output(self.name, process) + code = await process.wait() + if not self._running: + return + if not self._restart_now.is_set(): + if time.monotonic() - self._started >= HEALTHY_RUN: + self._failures = 0 + await self._back_off( + f"exited with {code}" + (f": {last_line}" if last_line else "") + ) + while self._running: + try: + await self._spawn() + except OSError as err: + # No new process: back off further before the next try. + await self._back_off(f"could not start: {err}") + continue + self.restarts += 1 + break + + async def _back_off(self, message: str) -> None: + """Report ``message`` and wait the next backoff delay or a restart.""" + delay = min(self.restart_delay * 2**self._failures, MAX_RESTART_DELAY) + self._failures += 1 + self._error(message, delay) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._restart_now.wait(), delay) + + def _error(self, message: str, delay: float) -> None: + self.last_error = message + logger.warning( + "ffmpeg for %s %s, restarting in %.1f s", self.name, message, delay + ) + if self.on_error is not None: + try: + self.on_error(self.name, message) + except Exception: + logger.exception("Error in the ffmpeg error callback") + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Restart ffmpeg if its stream has gone without IRAP pictures. + + ``gop`` is the IRAP interval the source measured, if it did. + """ + limit = 2 * (gop or self.gop) + if not self._running or self._restart_now.is_set(): + return + if age is not None and age <= limit: + return + running = time.monotonic() - self._started + if running < limit: + # Started recently, its first IRAP picture is on the way. + return + logger.info( + "Restarting ffmpeg for %s for an IRAP picture (last %s s ago)", + self.name, + "-" if age is None else f"{age:.1f}", + ) + self._restart_now.set() + if self._process is not None and self._process.returncode is None: + self._process.terminate() + + async def stop(self) -> None: + """Stop ffmpeg, killing it if it does not exit in time.""" + self._running = False + self._restart_now.set() + await self._terminate() + if self._watcher is not None: + await self._watcher + # The watcher may have started one more meanwhile. + await self._terminate() + process = self._process + if process is not None: + logger.info( + "ffmpeg for %s stopped (exit %s, %d restarts)", + self.name, + process.returncode, + self.restarts, + ) + + async def _terminate(self) -> None: + process = self._process + if process is None or process.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), FFMPEG_STOP_TIMEOUT) + except TimeoutError: + logger.warning("Killing ffmpeg for %s", self.name) + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.wait() + + +async def _log_output(name: str, process: asyncio.subprocess.Process) -> str | None: + """Log ffmpeg's output line by line, URLs redacted; progress ends with CR. + + Returns the last line, redacted, to explain an exit. + """ + stream = process.stderr + if stream is None: + return None + lines = StderrLines() + last: bytes | None = None + while chunk := await stream.read(4096): + for line in lines.feed(chunk): + _log_line(name, line) + last = line + for line in lines.end(): + _log_line(name, line) + last = line + if last is None: + return None + return redact_urls(last.decode(errors="replace")).strip() or None + + +class StderrLines: + """Splits ffmpeg's stderr into lines of at most about MAX_LOG_LINE bytes. + + An overlong line is returned once, cut, and its rest skipped up to the + next line end. Logging it in parts could split a URL so that its + credentials escape redaction. + """ + + def __init__(self) -> None: + self.pending = b"" + self._skipping = False + + def feed(self, chunk: bytes) -> list[bytes]: + """Add output, return the lines it completes.""" + lines = (self.pending + chunk).replace(b"\r", b"\n").split(b"\n") + self.pending = lines.pop() + if self._skipping and lines: + lines.pop(0) + self._skipping = False + done = [_cut(line) for line in lines if line] + if self._skipping: + self.pending = b"" + elif len(self.pending) > MAX_LOG_LINE: + done.append(_cut(self.pending)) + self.pending = b"" + self._skipping = True + return done + + def end(self) -> list[bytes]: + """Return the last line, which had no line end.""" + return [self.pending] if self.pending else [] + + +def _cut(line: bytes) -> bytes: + """Cut an overlong line before its last URL and its last partial word. + + A URL may hold spaces in its userinfo, so a cut URL is dropped whole. + """ + if len(line) <= MAX_LOG_LINE: + return line + head = line[:MAX_LOG_LINE] + if (url := head.rfind(b"://")) != -1: + head = head[:url] + return head.rpartition(b" ")[0] + b" [truncated]" + + +def _log_line(name: str, line: bytes) -> None: + logger.debug("ffmpeg %s: %s", name, redact_urls(line.decode(errors="replace"))) diff --git a/pyhap/media/pool.py b/pyhap/media/pool.py new file mode 100644 index 00000000..79871ca0 --- /dev/null +++ b/pyhap/media/pool.py @@ -0,0 +1,315 @@ +"""Media sources shared by the sessions of a camera channel. + +:class:`SourcePool` gives each session a +:class:`ChannelLease` on the media source of its camera channel. All +sessions of a channel share one source and one media pull +(:class:`ChannelPull`, for example an ffmpeg process), so N sessions cost one +camera connection. Sources live in a +:class:`~pyhap.media.worker.MediaWorker`, or in process without one. + +The ports of a source are bound by the first lease; the pull starts with the +first answered session (:meth:`ChannelLease.activate`) and stops with the +last one; the source is closed with the last lease. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine, Hashable +from dataclasses import dataclass, field +from functools import partial +import inspect +import logging +from typing import TYPE_CHECKING, Any, Protocol + +from . import MediaError + +if TYPE_CHECKING: + from .worker import MediaWorker + +logger = logging.getLogger(__name__) + + +class MediaSourceLike(Protocol): + """A :class:`~pyhap.media.source.MediaSource` or its worker proxy.""" + + on_closed: Callable[[str], None] | None + + @property + def closed(self) -> bool: + """Whether the source has ended.""" + + @property + def rtp_input(self) -> tuple[str, int, int]: + """``(host, video_port, audio_port)`` the pull sends to.""" + + @property + def has_audio(self) -> bool: + """Whether the source takes audio, i.e. has an audio input port.""" + + def close(self, reason: str = "closed") -> Awaitable[None] | None: + """Release the ports and end every session on the source.""" + + +class ChannelPull(Protocol): + """The media pull of one camera channel, for example an ffmpeg process.""" + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Start sending plain RTP to ``(host, video_port, audio_port)``.""" + + async def stop(self) -> None: + """Stop sending; called once after a successful :meth:`start`.""" + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """A session needs an IRAP picture.""" + + +class SourceLease(Protocol): + """A session's claim on the media source of its camera channel.""" + + @property + def source(self) -> MediaSourceLike: + """The shared source.""" + + async def activate(self) -> None: + """The session was answered: the channel must be pulled now.""" + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Forward a keyframe request of the session's sender.""" + + async def release(self) -> None: + """The session ended; called exactly once.""" + + +@dataclass(eq=False) +class _Channel: + key: Hashable + name: str + source: MediaSourceLike + pull: ChannelPull + leases: int = 0 + active: int = 0 + pulling: bool = False + closed: bool = False + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class ChannelLease: + """A :class:`SourceLease` of a :class:`SourcePool`.""" + + def __init__(self, pool: SourcePool, channel: _Channel) -> None: + """Use :meth:`SourcePool.lease`.""" + self._pool = pool + self._channel = channel + self.active = False + self.released = False + + @property + def source(self) -> MediaSourceLike: + """The channel's source.""" + return self._channel.source + + @property + def channel(self) -> str: + """The channel's name.""" + return self._channel.name + + async def activate(self) -> None: + """Count the session as answered; the first one starts the pull.""" + await self._pool._activate(self) + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Pass the request on while the channel is pulled.""" + channel = self._channel + if self.active and channel.pulling and not channel.closed: + channel.pull.keyframe_requested(age, gop) + + async def release(self) -> None: + """Give up the claim; the last session stops the pull and the source.""" + await asyncio.shield(self._pool._release(self)) + + +class SourcePool: + """Shares one media source and one pull per camera channel. + + A channel is any hashable key; leases of equal keys share the source. + Sources and senders run in ``worker``, or in process without one. + """ + + def __init__( + self, worker: MediaWorker | None = None, host: str = "127.0.0.1" + ) -> None: + """Configure the pool; sources are opened by the first lease.""" + self.worker = worker + self.host = host + self._channels: dict[Hashable, _Channel] = {} + self._lock = asyncio.Lock() + self._tasks: set[asyncio.Task[Any]] = set() + + def counts(self) -> dict[str, tuple[int, int]]: + """Leases and answered leases per channel name.""" + return {c.name: (c.leases, c.active) for c in self._channels.values()} + + async def lease( + self, + channel: Hashable, + pull: Callable[[], ChannelPull], + *, + audio: bool = True, + name: str | None = None, + ) -> ChannelLease: + """Lease the source of ``channel``, opening it with ``pull`` if needed. + + ``pull`` makes the channel's pull when a new source is opened. + ``name`` is what the log shows instead of the key. + """ + task = asyncio.ensure_future(self._lease(channel, pull, audio, name)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + # The lease is made anyway; give it back once it is. + task.add_done_callback(self._release_orphan) + raise + + async def _lease( + self, + key: Hashable, + pull: Callable[[], ChannelPull], + audio: bool, + name: str | None, + ) -> ChannelLease: + async with self._lock: + channel = self._channels.get(key) + if channel is None: + # Made first: a failing factory leaves no open source behind. + channel_pull = pull() + source = await self._open_source(audio) + channel = _Channel(key, name or str(key), source, channel_pull) + source.on_closed = partial(self._source_closed, channel) + if source.closed: + # It ended before on_closed was set, nothing will report it. + raise MediaError(f"Media source {channel.name} ended") + self._channels[key] = channel + logger.debug("Source %s opened at %s", channel.name, source.rtp_input) + channel.leases += 1 + self._log(channel) + return ChannelLease(self, channel) + + async def _open_source(self, audio: bool) -> MediaSourceLike: + if self.worker is not None: + return await self.worker.open_source(self.host, audio) + from .source import MediaSource # noqa: PLC0415 + + source = MediaSource(self.host, audio) + try: + await source.start() + except BaseException: + source.close("start failed") + raise + return source + + def _release_orphan(self, task: asyncio.Task[ChannelLease]) -> None: + if not task.cancelled() and task.exception() is None: + self._spawn(task.result().release()) + + def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + task = asyncio.ensure_future(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _activate(self, lease: ChannelLease) -> None: + channel = lease._channel + if lease.released or lease.active: + return + if channel.closed: + raise MediaError(f"Media source {channel.name} is closed") + lease.active = True + channel.active += 1 + try: + await self._sync_pull(channel) + except BaseException: + # A release while the pull started has already counted it off. + if lease.active: + lease.active = False + channel.active -= 1 + raise + self._log(channel) + + async def _release(self, lease: ChannelLease) -> None: + if lease.released: + return + lease.released = True + channel = lease._channel + channel.leases -= 1 + if lease.active: + lease.active = False + channel.active -= 1 + last = channel.leases == 0 and not channel.closed + if last: + # From here on a new lease opens a new source. + channel.closed = True + self._forget(channel) + self._log(channel) + await self._sync_pull(channel) + if last: + await self._close_source(channel, "no sessions left") + + async def _sync_pull(self, channel: _Channel) -> None: + """Start or stop the pull so it runs exactly while sessions are answered.""" + async with channel.lock: + wanted = channel.active > 0 and not channel.closed + if wanted and not channel.pulling: + logger.debug("Starting the pull of source %s", channel.name) + await channel.pull.start(channel.source.rtp_input) + channel.pulling = True + elif not wanted and channel.pulling: + channel.pulling = False + logger.debug("Stopping the pull of source %s", channel.name) + try: + await channel.pull.stop() + except Exception: + logger.exception("Stopping the pull of %s failed", channel.name) + + async def _close_source(self, channel: _Channel, reason: str) -> None: + source = channel.source + source.on_closed = None + try: + result = source.close(reason) + if inspect.isawaitable(result): + await result + except Exception: + logger.exception("Closing source %s failed", channel.name) + logger.debug("Source %s closed: %s", channel.name, reason) + + def _forget(self, channel: _Channel) -> None: + if self._channels.get(channel.key) is channel: + del self._channels[channel.key] + + def _source_closed(self, channel: _Channel, reason: str) -> None: + """The source ended on its own, e.g. its worker exited.""" + if channel.closed: + return + logger.warning("Source %s ended: %s", channel.name, reason) + channel.closed = True + self._forget(channel) + self._spawn(self._sync_pull(channel)) + + def _log(self, channel: _Channel) -> None: + logger.debug( + "Source %s: %d leases, %d answered%s", + channel.name, + channel.leases, + channel.active, + ", closed" if channel.closed else "", + ) + + async def close(self) -> None: + """Stop every pull and close every source, e.g. on shutdown.""" + channels = list(self._channels.values()) + self._channels.clear() + for channel in channels: + channel.closed = True + await self._sync_pull(channel) + await self._close_source(channel, "pool closed") + while self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) diff --git a/tests/test_media_ffmpeg_provider.py b/tests/test_media_ffmpeg_provider.py new file mode 100644 index 00000000..7b4fe6c9 --- /dev/null +++ b/tests/test_media_ffmpeg_provider.py @@ -0,0 +1,408 @@ +"""Tests for pyhap.media.ffmpeg with a stand-in ffmpeg and a fake worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +import os +from pathlib import Path +import signal +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from pyhap import hksv3 +from pyhap.camera_stream import StreamError +from pyhap.media import ffmpeg as ffmpeg_module +from pyhap.media.ffmpeg import ( + FFmpegInput, + FFmpegPull, + FFmpegSourceProvider, + redact_urls, +) + +from .webrtc_fakes import ( + AUDIO, + LOW, + MEDIUM, + FakeWorker, + make_session, + write_fake_ffmpeg, +) + +pytestmark = pytest.mark.timeout(5) + +TIER_ONE = hksv3.VideoStreamTier(1, 3, 1, 1, 1, 1) +DEFAULTS = ("0:v:0", "0:a:0", True, 2.0) +INPUTS: dict[int | hksv3.VideoQuality | str, list[str] | FFmpegInput] = { + 1: ["-i", "tier-one"], + hksv3.VideoQuality.MEDIUM: ["-i", "medium"], + "low": FFmpegInput(("-i", "low"), video_map="0:1", audio=False, gop=4.0), +} + + +@pytest.fixture +def fake_ffmpeg(tmp_path: Path) -> Path: + """An ffmpeg stand-in.""" + return write_fake_ffmpeg(tmp_path) + + +async def wait_until(predicate: Callable[[], bool]) -> None: + """Poll a predicate on the event loop.""" + while not predicate(): + await asyncio.sleep(0.01) + + +@pytest.mark.parametrize( + ("tier", "expected", "name"), + [ + (TIER_ONE, FFmpegInput(("-i", "tier-one"), *DEFAULTS), "tier 1"), + (MEDIUM, FFmpegInput(("-i", "medium"), *DEFAULTS), "medium"), + (LOW, FFmpegInput(("-i", "low"), "0:1", "0:a:0", False, 4.0), "low"), + ], +) +async def test_ffmpeg_inputs( + tier: hksv3.VideoStreamTier, expected: FFmpegInput, name: str +) -> None: + """Verify an identifier wins over a quality, also given by name.""" + provider = FFmpegSourceProvider(INPUTS) + + assert await provider.input_for(tier) == expected + assert provider.channel_name(tier) == name + + +async def test_ffmpeg_no_input() -> None: + """Verify a tier without an input refuses the offer.""" + provider = FFmpegSourceProvider({"high": ["-i", "high"]}) + + with pytest.raises(StreamError, match="No ffmpeg input"): + await provider.input_for(LOW) + + +async def resolve(tier: hksv3.VideoStreamTier) -> list[str] | None: + """An input resolver with an input for the Medium tier only.""" + return ["-i", f"resolved-{tier.identifier}"] if tier is MEDIUM else None + + +async def test_ffmpeg_input_resolver() -> None: + """Verify an async resolver maps tiers to inputs, None refusing the offer.""" + provider = FFmpegSourceProvider(resolve, video_map="0:2", audio=False, gop=1.0) + + found = await provider.input_for(MEDIUM) + + assert found == FFmpegInput(("-i", "resolved-2"), "0:2", "0:a:0", False, 1.0) + with pytest.raises(StreamError, match="No ffmpeg input"): + await provider.input_for(LOW) + + +def test_ffmpeg_channel_name_of_an_unknown_quality() -> None: + """Verify a tier of an unknown quality is named by its identifier.""" + provider = FFmpegSourceProvider(resolve) + + assert provider.channel_name(hksv3.VideoStreamTier(7, 9, 1, 1, 1, 1)) == "tier 7" + + +def test_ffmpeg_command() -> None: + """Verify ffmpeg copies H.265 and encodes Opus to the source's ports.""" + provider = FFmpegSourceProvider(INPUTS, ffmpeg="/bin/ffmpeg", stats_period=10) + found = FFmpegInput(("-i", "cam"), "0:v:1", "0:a:2") + + command = provider.command(found, ("127.0.0.1", 6000, 6002), 24000) + + assert command == [ + *("/bin/ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning"), + *("-stats", "-stats_period", "10", "-i", "cam"), + *("-map", "0:v:1", "-c:v", "copy", "-bsf:v", "dump_extra"), + *("-payload_type", "99", "-rtpflags", "skip_rtcp"), + *("-f", "rtp", "rtp://127.0.0.1:6000"), + *("-map", "0:a:2", "-c:a", "libopus", "-ac", "1", "-ar", "48000"), + *("-b:a", "24000", "-application", "lowdelay", "-frame_duration", "20"), + *("-payload_type", "110", "-rtpflags", "skip_rtcp"), + *("-f", "rtp", "rtp://127.0.0.1:6002"), + ] + + +def test_ffmpeg_command_packet_time() -> None: + """Verify the Opus frame duration follows the audio tier's packet time.""" + provider = FFmpegSourceProvider(INPUTS) + + command = provider.command( + FFmpegInput(("-i", "cam")), ("127.0.0.1", 6000, 6002), 32000, packet_time=40 + ) + + assert command[command.index("-frame_duration") + 1] == "40" + assert command[command.index("-b:a") + 1] == "32000" + + +async def test_ffmpeg_provider_without_audio_tier(fake_ffmpeg: Path) -> None: + """Verify a multi-tier session without audio leases with the default tier.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + + lease = await provider(make_session(), LOW, None) + + assert lease.channel == "low" + await lease.release() + + +def test_ffmpeg_command_without_audio() -> None: + """Verify a source without audio gets video only, IPv6 in brackets.""" + provider = FFmpegSourceProvider(INPUTS) + + command = provider.command(FFmpegInput(("-i", "cam")), ("::1", 6000, 0), 24000) + + assert command[-1] == "rtp://[::1]:6000" + assert command[command.index("-map") + 1] == "0:v:0" + assert "-nostats" in command + assert "libopus" not in command + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ( + "rtsps://user:pw@192.168.1.1:7441/T0k3n?enableSrtp: 404 Not Found", + "rtsps://192.168.1.1:7441/ 404 Not Found", + ), + ( + "Input #0, rtsp, from 'rtsp://[::1]:554/a/b':", + "Input #0, rtsp, from 'rtsp://[::1]:554/':", + ), + ("http://cam.local:80 timed out", "http://cam.local:80 timed out"), + ("frame= 25 fps=0.0 q=-1.0", "frame= 25 fps=0.0 q=-1.0"), + ( + "rtsp://my user:p w@10.0.0.2:554/live failed", + "rtsp://10.0.0.2:554/ failed", + ), + ( + """rtsp://user:p'w"x@10.0.0.2/live failed""", + "rtsp://10.0.0.2/ failed", + ), + ], + ids=[ + "credentials-token-query", + "quoted-ipv6", + "host-only", + "no-url", + "userinfo-spaces", + "userinfo-quotes", + ], +) +def test_redact_urls(line: str, expected: str) -> None: + """Verify only scheme, host and port of a URL stay in the log.""" + assert redact_urls(line) == expected + + +async def test_overlong_stderr_line_is_cut(caplog: pytest.LogCaptureFixture) -> None: + """Verify ffmpeg's log cuts an overlong line and goes on after it.""" + caplog.set_level(logging.DEBUG, "pyhap.media.ffmpeg") + stderr = asyncio.StreamReader() + stderr.feed_data(b"x" * (3 * ffmpeg_module.MAX_LOG_LINE) + b"\nnext\nlast") + stderr.feed_eof() + + await ffmpeg_module._log_output("medium", cast(Any, SimpleNamespace(stderr=stderr))) + + assert caplog.messages == [ + "ffmpeg medium: [truncated]", + "ffmpeg medium: next", + "ffmpeg medium: last", + ] + + +def test_overlong_line_is_skipped_without_buffering() -> None: + """Verify the rest of an overlong line is skipped, not buffered.""" + lines = ffmpeg_module.StderrLines() + word = b"word " * (ffmpeg_module.MAX_LOG_LINE // 5) + + assert lines.feed(word + b"tail") == [word[:-1] + b" [truncated]"] + assert lines.pending == b"" + assert lines.feed(b"y" * ffmpeg_module.MAX_LOG_LINE) == [] + assert lines.pending == b"" + assert lines.feed(b"rest\rnext\n") == [b"next"] + assert lines.feed(b"after\n") == [b"after"] + assert lines.end() == [] + + +def test_line_over_the_limit_across_reads_is_cut() -> None: + """Verify a complete line longer than the limit is cut too.""" + lines = ffmpeg_module.StderrLines() + + assert lines.feed(b"a " * 2000) == [] + assert lines.feed(b"b" * 200 + b"\n") == [b"a " * 1999 + b"a [truncated]"] + + +@pytest.mark.parametrize( + "url", + [ + b"rtsp://admin:pw@10.0.0.2/live", + b"rtsp://admin:pa ss@10.0.0.2/live", + b"'rtsp://admin:pw@10.0.0.2/live'", + ], + ids=["userinfo", "userinfo-space", "quoted"], +) +def test_cut_drops_a_url_cut_in_two(url: bytes) -> None: + """Verify a URL running over the limit is dropped, not split.""" + line = b"x " * ((ffmpeg_module.MAX_LOG_LINE - 20) // 2) + url + b" tail" + + (cut,) = ffmpeg_module.StderrLines().feed(line + b"\n") + + assert b"admin" not in cut + assert cut.endswith(b"x [truncated]") + + +def test_cut_keeps_a_complete_url() -> None: + """Verify only the URL cut in two is dropped, not an earlier one.""" + line = ( + b"x " * ((ffmpeg_module.MAX_LOG_LINE - 40) // 2) + + b"rtsp://cam1/a rtsp://admin:pw@10.0.0.2/live tail" + ) + + (cut,) = ffmpeg_module.StderrLines().feed(line + b"\n") + + assert cut.endswith(b" rtsp://cam1/a [truncated]") + + +async def test_ffmpeg_provider_without_audio(fake_ffmpeg: Path) -> None: + """Verify an input without audio opens a video-only source.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + + lease = await provider(make_session(), LOW, AUDIO) + + assert lease.source.rtp_input == ("127.0.0.1", 5000, 0) + assert lease.channel == "low" + await lease.release() + + +async def test_provider_diagnostics() -> None: + """Verify the diagnostics name each leased channel with its leases.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + lease = await provider(make_session(), LOW, AUDIO) + channels = provider.diagnostics()["channels"] + assert channels["low"]["leases"] == 1 + assert channels["low"]["restarts"] == 0 + await lease.release() + + +def pull_on(fake_ffmpeg: Path, gop: float = 10.0) -> FFmpegPull: + """An ffmpeg pull running the fake ffmpeg.""" + return FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + gop, + restart_delay=0.01, + ) + + +async def test_ffmpeg_pull_restarts_after_exit(fake_ffmpeg: Path) -> None: + """Verify ffmpeg is started again when it exits while it is needed.""" + (fake_ffmpeg.parent / "exit-now").touch() + pull = pull_on(fake_ffmpeg) + + await pull.start(("127.0.0.1", 1, 2)) + await wait_until(lambda: pull.restarts >= 2) + (fake_ffmpeg.parent / "exit-now").unlink() + await pull.stop() + + assert pull.pid is None + + +async def test_ffmpeg_pull_reports_exits_and_backs_off(fake_ffmpeg: Path) -> None: + """Verify exits reach on_error with the redacted last line, and back off.""" + (fake_ffmpeg.parent / "exit-now").touch() + errors: list[tuple[str, str]] = [] + pull = FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda name, message: errors.append((name, message)), + ) + await pull.start(("127.0.0.1", 1, 2)) + await wait_until(lambda: len(errors) >= 3) + (fake_ffmpeg.parent / "exit-now").unlink() + await pull.stop() + name, message = errors[0] + assert name == "medium" + assert message.startswith("exited with 1: rtsps://192.168.1.1:7441/") + assert "pw" not in message + assert pull.diagnostics()["last_error"] == message + assert pull._failures >= 3 + + +async def test_ffmpeg_pull_backs_off_failed_starts( + fake_ffmpeg: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify failed starts keep backing off, each reported once, not counted.""" + # Every run counts as healthy, so only the failed starts may back off. + monkeypatch.setattr(ffmpeg_module, "HEALTHY_RUN", 0.0) + errors: list[str] = [] + pull = FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda _name, message: errors.append(message), + ) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + assert pid is not None + fake_ffmpeg.rename(fake_ffmpeg.with_name("gone")) + os.kill(pid, signal.SIGTERM) + await wait_until(lambda: len(errors) >= 4) + await pull.stop() + + assert errors[0].startswith("exited with") + assert all(message.startswith("could not start") for message in errors[1:]) + assert pull._failures == len(errors) + assert pull.restarts == 0 + + +@pytest.mark.parametrize( + ("gop", "age", "measured"), + [ + (10.0, None, None), # Started recently: its first IRAP picture is on the way. + (0.01, 0.01, None), # The last IRAP picture is recent. + (0.01, 1.0, 10.0), # The measured GOP is long: the next one is on the way. + ], + ids=["starting", "recent", "measured-gop"], +) +async def test_ffmpeg_pull_keeps_running( + fake_ffmpeg: Path, gop: float, age: float | None, measured: float | None +) -> None: + """Verify a keyframe request does not restart a healthy ffmpeg.""" + pull = pull_on(fake_ffmpeg, gop) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + await asyncio.sleep(0.05) + + pull.keyframe_requested(age, measured) + await asyncio.sleep(0.05) + + assert (pull.pid, pull.restarts) == (pid, 0) + await pull.stop() + + +@pytest.mark.parametrize( + ("gop", "age", "measured"), + [(0.01, None, None), (0.01, 1.0, None), (10.0, 1.0, 0.01)], + ids=["no-irap", "configured-gop", "measured-gop"], +) +async def test_ffmpeg_pull_restarts_for_a_keyframe( + fake_ffmpeg: Path, gop: float, age: float | None, measured: float | None +) -> None: + """Verify ffmpeg restarts when no IRAP picture came for two GOPs.""" + pull = pull_on(fake_ffmpeg, gop) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + pull._started -= 1 + await asyncio.sleep(0.05) + + pull.keyframe_requested(age, measured) + await wait_until(lambda: pull.restarts == 1 and pull.pid is not None) + + assert pull.pid != pid + await pull.stop() diff --git a/tests/webrtc_fakes.py b/tests/webrtc_fakes.py new file mode 100644 index 00000000..dfe4faa8 --- /dev/null +++ b/tests/webrtc_fakes.py @@ -0,0 +1,229 @@ +"""Fakes of the media worker and sessions for the media tests (not a test module). + +The fake worker stands in for both the in-process and the worker media. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterable +import pathlib +import sys +from typing import Any +from uuid import UUID, uuid4 + +from pyhap import hksv3 +from pyhap.camera_webrtc import WebRTCSession +from pyhap.media import MediaError +from pyhap.media.pool import ChannelLease, SourcePool + +FAKE_OFFER = "v=0\r\na=offer\r\n" +FAKE_REOFFER_ANSWER = "v=0\r\na=reoffer-answer\r\n" +SENSOR = hksv3.SensorConfiguration( + UUID("00112233-4455-6677-8899-aabbccddeeff"), + 3840, + 2160, + video_streams=[ + hksv3.VideoStreamCapability(UUID(int=1), 2, 1920, 1080, 30, 1700, 1800), + hksv3.VideoStreamCapability(UUID(int=2), 3, 1280, 720, 30, 768, 800), + hksv3.VideoStreamCapability(UUID(int=3), 4, 640, 360, 15, 180, 190), + ], +) +MEDIUM = hksv3.tiers_from_sensor(SENSOR)[1] +LOW = hksv3.tiers_from_sensor(SENSOR)[2] +AUDIO = hksv3.DEFAULT_AUDIO_TIER + + +def make_session(sframe: bool = True) -> WebRTCSession: + """A WebRTC session on the Medium tier, outside any controller.""" + return WebRTCSession( + id=uuid4(), + sensor=SENSOR, + client_addr=None, + sframe=sframe, + video_tier=MEDIUM, + audio_tier=AUDIO, + ) + + +def write_fake_ffmpeg(directory: pathlib.Path) -> pathlib.Path: + """Write an ffmpeg stand-in: logs its arguments, prints progress, waits. + + It exits at once with 1 while ``exit-now`` exists next to it, and prints + a camera URL with credentials and a token. + """ + script = directory / "ffmpeg" + script.write_text( + f"#!{sys.executable}\n" + "import pathlib, signal, sys, time\n" + "here = pathlib.Path(__file__).parent\n" + "with open(here / 'starts.log', 'a') as log:\n" + " log.write(' '.join(sys.argv[1:]) + '\\n')\n" + "sys.stderr.write('frame=1 fps=0\\rframe=2 fps=0\\nwarning\\n')\n" + "sys.stderr.write('rtsps://user:pw@192.168.1.1:7441/T0k3n?x=1: 404\\n')\n" + "sys.stderr.flush()\n" + "if (here / 'exit-now').exists():\n" + " sys.exit(1)\n" + "signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n" + "time.sleep(60)\n" + ) + script.chmod(0o755) + return script + + +class FakeSource: + """Stands in for a MediaSource or RemoteMediaSource.""" + + def __init__(self, port: int, audio: bool) -> None: + """Pretend to be bound.""" + self.rtp_input = ("127.0.0.1", port, port + 1 if audio else 0) + self.on_closed: Callable[[str], None] | None = None + self.close_reasons: list[str] = [] + + @property + def closed(self) -> bool: + """Whether the source was closed.""" + return bool(self.close_reasons) + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self.rtp_input[2] != 0 + + def keyframe_age(self) -> float | None: + """No IRAP picture seen yet.""" + return None + + async def close(self, reason: str = "closed") -> None: + """Record the close.""" + self.close_reasons.append(reason) + + def end(self, reason: str) -> None: + """End on its own, as when the worker exits.""" + assert self.on_closed is not None + self.on_closed(reason) + + +class FakeSender: + """Stands in for a WebRTCSender, recording every call.""" + + def __init__(self, **options: Any) -> None: + """Keep the options the delegate created it with.""" + self.options = options + self.on_closed: Callable[[str], None] | None = None + self.on_connected: Callable[[], None] | None = None + self.on_keyframe_request: ( + Callable[[float | None, float | None], None] | None + ) = None + self.on_audio: Callable[[bytes, int], None] | None = None + self.calls: list[tuple[Any, ...]] = [] + self.error: MediaError | None = None + + def _call(self, *call: Any) -> None: + self.calls.append(call) + if self.error is not None: + raise self.error + + async def create_offer(self) -> str: + """Return a fixed offer.""" + self._call("offer") + return FAKE_OFFER + + async def apply_answer(self, sdp: str, candidates: Iterable[Any]) -> None: + """Record the answer.""" + self._call("answer", sdp, list(candidates)) + + async def reoffer(self, sdp: str) -> str: + """Return a fixed answer.""" + self._call("reoffer", sdp) + return FAKE_REOFFER_ANSWER + + async def update_receive_keys( + self, add: Iterable[Any] = (), remove: Iterable[int] = () + ) -> None: + """Record the keys.""" + self._call("keys", list(add), list(remove)) + + async def close(self) -> None: + """Record the close; never fails.""" + self.calls.append(("close",)) + + +class FakeWorker: + """Stands in for a MediaWorker.""" + + pid = 4242 + + def __init__(self) -> None: + """Start without sources or senders.""" + self.sources: list[FakeSource] = [] + self.senders: list[FakeSender] = [] + self.end_on_open: str | None = None + + async def open_source(self, host: str = "127.0.0.1", audio: bool = True) -> Any: + """Open a fake source on new ports, already ended if ``end_on_open``.""" + source = FakeSource(5000 + 2 * len(self.sources), audio) + if self.end_on_open is not None: + source.close_reasons.append(self.end_on_open) + self.sources.append(source) + return source + + def create_sender(self, **options: Any) -> Any: + """Create a fake sender.""" + sender = FakeSender(**options) + self.senders.append(sender) + return sender + + +class FakePull: + """Stands in for an ffmpeg pull.""" + + def __init__(self) -> None: + """Not running yet.""" + self.started: list[tuple[str, int, int]] = [] + self.stopped = 0 + self.keyframes: list[tuple[float | None, float | None]] = [] + self.error: Exception | None = None + self.gate: asyncio.Event | None = None + self.waiting = False + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Record the start, or fail; wait for ``gate`` first if set.""" + if self.gate is not None: + self.waiting = True + await self.gate.wait() + if self.error is not None: + raise self.error + self.started.append(rtp_input) + + async def stop(self) -> None: + """Record the stop.""" + self.stopped += 1 + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Record the request.""" + self.keyframes.append((age, gop)) + + +class FakeProvider: + """A source provider with a channel per tier quality and fake pulls.""" + + def __init__(self, pool: SourcePool) -> None: + """Lease from ``pool``.""" + self.pool = pool + self.pulls: list[FakePull] = [] + + async def __call__( + self, + session: WebRTCSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier, + ) -> ChannelLease: + """Lease the channel of the tier's quality.""" + name = hksv3.VideoQuality(video_tier.quality).name.lower() + return await self.pool.lease(video_tier.quality, self._pull, name=name) + + def _pull(self) -> FakePull: + pull = FakePull() + self.pulls.append(pull) + return pull From 3c10af7108bd70ecf1254965e84a6510473898cb Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:57:13 +0100 Subject: [PATCH 37/56] Run media sources in a worker process MediaWorker keeps the per-packet media work off the caller's event loop: one process for all cameras, length-prefixed JSON frames over pipes, forwarded logs and proxies with the async API of the in-process objects. --- pyhap/media/_worker.py | 183 +++++++++++++++++ pyhap/media/worker.py | 395 +++++++++++++++++++++++++++++++++++++ tests/test_media_worker.py | 112 +++++++++++ 3 files changed, 690 insertions(+) create mode 100644 pyhap/media/_worker.py create mode 100644 pyhap/media/worker.py create mode 100644 tests/test_media_worker.py diff --git a/pyhap/media/_worker.py b/pyhap/media/_worker.py new file mode 100644 index 00000000..ccca28d8 --- /dev/null +++ b/pyhap/media/_worker.py @@ -0,0 +1,183 @@ +"""The media worker process, see :mod:`pyhap.media.worker`. + +Started as ``python -m pyhap.media._worker ``, one level per +logger in ``FORWARDED_LOGGERS``. Requests arrive framed on stdin; responses +and events leave framed on the original stdout, which is moved aside first. +The process ends when stdin closes. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import sys +from typing import Any + +from . import MediaError +from .source import MediaSource +from .worker import FORWARDED_LOGGERS, encode_message, read_message + +logger = logging.getLogger(__name__) + + +class _LogForwarder(logging.Handler): + def __init__(self, server: _Server) -> None: + super().__init__() + self._server = server + + def emit(self, record: logging.LogRecord) -> None: + try: + message = self.format(record) + self._server.send( + { + "event": "log", + "level": record.levelno, + "name": record.name, + "message": message, + } + ) + except Exception: # noqa: BLE001 - logging must never raise + self.handleError(record) + + +class _Server: + def __init__(self, output: asyncio.WriteTransport) -> None: + self._output = output + # The senders of the sessions, by session ID. + self._sessions: dict[int, Any] = {} + self._sources: dict[int, MediaSource] = {} + self._tasks: set[asyncio.Task[None]] = set() + self.stopping = asyncio.Event() + + def send(self, message: dict[str, Any], blob: bytes = b"") -> None: + if not self._output.is_closing(): + self._output.write(encode_message(message, blob)) + + def handle(self, message: dict[str, Any]) -> None: + task = asyncio.get_running_loop().create_task(self._respond(message)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _respond(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + try: + handler = getattr(self, f"_op_{message['op']}") + result = await handler(message) + except ( + MediaError, + ValueError, + KeyError, + TypeError, + RuntimeError, + OSError, + ) as err: + self.send({"id": request_id, "error": str(err) or type(err).__name__}) + return + except Exception as err: + logger.exception("Media worker request failed") + self.send({"id": request_id, "error": repr(err)}) + return + try: + self.send({"id": request_id, "result": result}) + except (ValueError, TypeError) as err: + # Too large or not serializable: the caller still gets an answer. + self.send({"id": request_id, "error": f"Answer not sent: {err}"}) + + async def _op_open_source(self, message: dict[str, Any]) -> dict[str, Any]: + source_id = int(message["source"]) + source = MediaSource(str(message["host"]), audio=bool(message["audio"])) + source.on_closed = lambda reason: self._source_closed(source_id, reason) + self._sources[source_id] = source + try: + rtp_input = await source.start() + except BaseException: + self._sources.pop(source_id, None) + source.on_closed = None + source.close() + raise + return {"rtp_input": list(rtp_input)} + + def _source_closed(self, source_id: int, reason: str) -> None: + if self._sources.pop(source_id, None) is not None: + self.send({"event": "source_closed", "source": source_id, "reason": reason}) + + async def _op_close_source(self, message: dict[str, Any]) -> None: + source = self._sources.pop(int(message["source"]), None) + if source is not None: + source.close(str(message.get("reason", "closed"))) + + async def _op_close(self, message: dict[str, Any]) -> None: + sender = self._sessions.pop(int(message["session"]), None) + if sender is not None: + await sender.close() + + async def _op_log_levels(self, message: dict[str, Any]) -> None: + for name, level in zip(FORWARDED_LOGGERS, message["levels"], strict=False): + logging.getLogger(name).setLevel(int(level)) + + async def _op_shutdown(self, message: dict[str, Any]) -> None: + await self.close_all() + self.stopping.set() + + async def close_all(self) -> None: + sessions, self._sessions = self._sessions, {} + await asyncio.gather( + *(sender.close() for sender in sessions.values()), return_exceptions=True + ) + sources, self._sources = self._sources, {} + for source in sources.values(): + source.on_closed = None + source.close("media worker stopped") + + +async def _serve(output_fd: int) -> None: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(0, "rb", buffering=0) + ) + output, _ = await loop.connect_write_pipe( + asyncio.Protocol, os.fdopen(output_fd, "wb", buffering=0) + ) + server = _Server(output) + forwarder = _LogForwarder(server) + for name in FORWARDED_LOGGERS: + logging.getLogger(name).addHandler(forwarder) + server.send({"event": "ready"}) + stopping = loop.create_task(server.stopping.wait()) + try: + while not server.stopping.is_set(): + reading = loop.create_task(read_message(reader)) + await asyncio.wait({reading, stopping}, return_when=asyncio.FIRST_COMPLETED) + if not reading.done(): + reading.cancel() + break + try: + message, _blob = reading.result() + except asyncio.IncompleteReadError: + break + server.handle(message) + finally: + stopping.cancel() + await server.close_all() + output.close() + + +def main() -> None: + """Run the worker until its stdin closes or it is told to stop.""" + # Keep the protocol stream for frames; anything printed goes to stderr. + output_fd = os.dup(1) + os.dup2(2, 1) + levels = [int(arg) for arg in sys.argv[1:]] + levels += [logging.WARNING] * (len(FORWARDED_LOGGERS) - len(levels)) + for name, level in zip(FORWARDED_LOGGERS, levels, strict=False): + logging.getLogger(name).setLevel(level) + logging.getLogger(name).propagate = False + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(_serve(output_fd)) + + +if __name__ == "__main__": + main() diff --git a/pyhap/media/worker.py b/pyhap/media/worker.py new file mode 100644 index 00000000..4293c8cc --- /dev/null +++ b/pyhap/media/worker.py @@ -0,0 +1,395 @@ +"""Run media sources in a worker process. + +SRTP and pacing cost CPU for every packet of every session. In an +application that also runs other work on its event loop (Home Assistant runs +HAP signalling on its main loop) the media path therefore runs in a separate +process. Only the signalling stays on the caller's loop: the proxies here +have the async API of :class:`~pyhap.media.source.MediaSource`, and their +events are called on the caller's loop. + +One :class:`MediaWorker` is one process with one asyncio loop, shared by +all cameras of the application. A process per camera would cost a Python +interpreter and the crypto imports for each camera, while one loop easily +carries several sessions; an integrator that wants isolation or more cores +can still create several workers. The process starts on first use (or with +:meth:`MediaWorker.start`). When it exits unexpectedly, every session +and source of it ends with a reason, and the next use starts a new one. + +The worker talks over its stdin and stdout pipes in length-prefixed frames: +a 4-byte JSON length, a 4-byte binary length, a JSON object and raw bytes. +Nothing is pickled. The worker moves its own stdout aside +so stray prints cannot corrupt the stream, and forwards the records of its +``pyhap`` loggers to the parent's loggers, at the parent's +levels. + +Fan-out: register a source per camera channel with +:meth:`MediaWorker.open_source`, point the media pull at its +``rtp_input`` and start each sender of that channel with it. +The source reassembles H.265 once; SRTP runs per session. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +import itertools +import logging +import os +import struct +import sys +from typing import Any + +import orjson + +from . import MediaError +from .sdp import AudioParams, VideoParams + +logger = logging.getLogger(__name__) + +HEADER = struct.Struct(">II") +MAX_MESSAGE = 4 * 1024 * 1024 +DEFAULT_START_TIMEOUT = 15.0 +# Requests run inside HAP writes, whose hooks get at most 9 s; one second less +# than the shortest sensible hook timeout leaves time to answer the write. +DEFAULT_REQUEST_TIMEOUT = 5.0 +STOP_TIMEOUT = 5.0 +WORKER_MODULE = "pyhap.media._worker" +# The loggers the worker forwards, each at the parent's effective level of +# the logger named here. +FORWARDED_LOGGERS = {"pyhap": "pyhap.media"} + + +class MediaWorkerError(MediaError): + """The media worker failed, exited or refused a request.""" + + +def encode_message(message: dict[str, Any], blob: bytes = b"") -> bytes: + """Frame one message and its binary attachment.""" + data = orjson.dumps(message) + if len(data) + len(blob) > MAX_MESSAGE: + raise ValueError("Media worker message too large") + return HEADER.pack(len(data), len(blob)) + data + blob + + +async def read_message(reader: asyncio.StreamReader) -> tuple[dict[str, Any], bytes]: + """Read one framed message; ``asyncio.IncompleteReadError`` at EOF.""" + size, blob_size = HEADER.unpack(await reader.readexactly(HEADER.size)) + if size + blob_size > MAX_MESSAGE: + raise ValueError("Media worker message too large") + message = orjson.loads(await reader.readexactly(size)) + if not isinstance(message, dict): + raise ValueError("Media worker message is not an object") + blob = await reader.readexactly(blob_size) if blob_size else b"" + return message, blob + + +def _call(callback: Callable[..., None] | None, *args: Any) -> None: + if callback is None: + return + try: + callback(*args) + except Exception: + logger.exception("Error in media worker callback") + + +class RemoteMediaSource: + """A :class:`~pyhap.media.source.MediaSource` in the worker process. + + ``on_closed(reason)`` fires when the worker exits. + """ + + def __init__(self, worker: MediaWorker, source_id: int, generation: int) -> None: + """Use :meth:`MediaWorker.open_source`.""" + self.on_closed: Callable[[str], None] | None = None + self.id = source_id + self.rtp_input: tuple[str, int, int] = ("", 0, 0) + self._worker = worker + self._generation = generation + self._closed = False + + @property + def closed(self) -> bool: + """Whether the source was closed or its worker exited.""" + return self._closed + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self.rtp_input[2] != 0 + + async def close(self, reason: str = "closed") -> None: + """Release the ports and end every session on this source.""" + if self._closed: + return + self._closed = True + self._worker._sources.pop(self.id, None) + with contextlib.suppress(MediaWorkerError): + await self._worker._request( + "close_source", self._generation, source=self.id, reason=reason + ) + + def _ended(self, reason: str) -> None: + if self._closed: + return + self._closed = True + _call(self.on_closed, reason) + + +class MediaWorker: + """A worker process running media sources.""" + + def __init__( + self, + *, + python: str | None = None, + start_timeout: float = DEFAULT_START_TIMEOUT, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Configure the worker; it starts on first use.""" + self.on_exit: Callable[[str], None] | None = None + self._python = python or sys.executable + self._start_timeout = start_timeout + self._request_timeout = request_timeout + self._process: asyncio.subprocess.Process | None = None + self._reader: asyncio.Task[None] | None = None + self._ready: asyncio.Future[None] | None = None + self._lock = asyncio.Lock() + self._generation = 0 + self._stopping = False + self._ids = itertools.count(1) + self._pending: dict[int, asyncio.Future[Any]] = {} + # The session proxies of the senders that run in the worker. + self._handles: dict[int, Any] = {} + self._sources: dict[int, RemoteMediaSource] = {} + + @property + def running(self) -> bool: + """Whether the worker process is up.""" + return self._process is not None and self._process.returncode is None + + @property + def pid(self) -> int | None: + """The worker's process ID while it runs.""" + return self._process.pid if self.running and self._process else None + + async def start(self) -> int: + """Start the worker unless it runs; return its generation.""" + async with self._lock: + if self.running and self._ready is not None and self._ready.done(): + return self._generation + await self._spawn() + return self._generation + + async def _spawn(self) -> None: + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + os.path.abspath(path) for path in sys.path if path + ) + levels = [str(level) for level in _log_levels()] + # -P and a fixed working directory: the worker uses exactly the + # parent's import path. + process = await asyncio.create_subprocess_exec( + self._python, + "-P", + "-m", + WORKER_MODULE, + *levels, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + env=env, + cwd=os.path.abspath(os.sep), + ) + if self._handles or self._sources or self._pending: + # The previous process exited and its reader did not get to it yet. + self._exited(self._generation, "media worker exited") + self._generation += 1 + self._stopping = False + self._process = process + self._ready = asyncio.get_running_loop().create_future() + self._reader = asyncio.get_running_loop().create_task( + self._read_loop(process, self._generation) + ) + try: + await asyncio.wait_for(asyncio.shield(self._ready), self._start_timeout) + except BaseException: + await self._kill(process) + raise + logger.debug("Media worker %s started", process.pid) + + async def update_log_levels(self) -> None: + """Apply the parent's current log levels in the worker. + + The worker starts with the levels of that moment; call this after + changing them, e.g. when an integration turns on debug logging. + """ + await self._request("log_levels", self._generation, levels=_log_levels()) + + async def open_source( + self, host: str = "127.0.0.1", audio: bool = True + ) -> RemoteMediaSource: + """Bind a media source in the worker; see ``rtp_input``.""" + generation = await self.start() + source = RemoteMediaSource(self, next(self._ids), generation) + self._sources[source.id] = source + try: + result = await self._request( + "open_source", generation, source=source.id, host=host, audio=audio + ) + except BaseException: + self._sources.pop(source.id, None) + source._closed = True + raise + source.rtp_input = tuple(result["rtp_input"]) # type: ignore[assignment] + return source + + async def close(self) -> None: + """End every session and stop the worker.""" + async with self._lock: + process = self._process + if process is None or process.returncode is not None: + return + self._stopping = True + with contextlib.suppress(MediaWorkerError, asyncio.TimeoutError): + await asyncio.wait_for( + self._request("shutdown", self._generation), STOP_TIMEOUT + ) + if process.stdin is not None: + process.stdin.close() + try: + await asyncio.wait_for(process.wait(), STOP_TIMEOUT) + except TimeoutError: + await self._kill(process) + if self._reader is not None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._reader + + async def _kill(self, process: asyncio.subprocess.Process) -> None: + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.wait() + + async def _request(self, op: str, generation: int, **fields: Any) -> Any: + process = self._process + if ( + generation != self._generation + or process is None + or process.stdin is None + or not self.running + ): + raise MediaWorkerError("Media worker is not running") + request_id = next(self._ids) + try: + data = encode_message({"id": request_id, "op": op, **fields}) + except (ValueError, TypeError) as err: + raise MediaWorkerError(f"Request {op} not sent: {err}") from err + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = future + try: + return await asyncio.wait_for( + self._exchange(process.stdin, data, future), self._request_timeout + ) + except (ConnectionError, BrokenPipeError) as err: + raise MediaWorkerError("Media worker is not running") from err + except TimeoutError as err: + # A worker that stops answering is stuck: end it, so its sessions + # end and the next use starts a new one. + logger.warning("Media worker did not answer %s, stopping it", op) + with contextlib.suppress(ProcessLookupError): + process.kill() + raise MediaWorkerError(f"Media worker did not answer {op}") from err + finally: + self._pending.pop(request_id, None) + + @staticmethod + async def _exchange( + stdin: asyncio.StreamWriter, data: bytes, future: asyncio.Future[Any] + ) -> Any: + """Send a request and wait for its answer, under one deadline.""" + stdin.write(data) + await stdin.drain() + return await future + + async def _read_loop( + self, process: asyncio.subprocess.Process, generation: int + ) -> None: + stdout = process.stdout + reason = "media worker exited" + try: + while stdout is not None: + message, blob = await read_message(stdout) + self._dispatch(message, blob) + except asyncio.IncompleteReadError: + pass + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - a broken stream ends the worker + logger.warning("Media worker sent invalid data: %r", err) + reason = "media worker protocol error" + with contextlib.suppress(ProcessLookupError): + process.kill() + code = await process.wait() + if self._stopping: + reason = "media worker stopped" + self._exited(generation, f"{reason} (code {code})") + + def _dispatch(self, message: dict[str, Any], blob: bytes) -> None: + if "id" in message: + future = self._pending.get(int(message["id"])) + if future is None or future.done(): + return + if "error" in message: + future.set_exception(MediaWorkerError(str(message["error"]))) + else: + future.set_result(message.get("result")) + return + event = message.get("event") + if event == "ready": + if self._ready is not None and not self._ready.done(): + self._ready.set_result(None) + elif event == "log": + logging.getLogger(str(message.get("name", __name__))).log( + int(message.get("level", logging.INFO)), + "[media worker] %s", + message.get("message", ""), + ) + elif event == "source_closed": + source = self._sources.pop(int(message["source"]), None) + if source is not None: + source._ended(str(message.get("reason", "closed"))) + elif "session" in message: + sender = self._handles.get(int(message["session"])) + if sender is not None: + sender._event(str(event), message, blob) + + def _exited(self, generation: int, reason: str) -> None: + if generation != self._generation: + return + logger.debug("Media worker ended: %s", reason) + if self._ready is not None and not self._ready.done(): + self._ready.set_exception(MediaWorkerError(reason)) + self._ready.exception() + for future in self._pending.values(): + if not future.done(): + future.set_exception(MediaWorkerError(reason)) + # Retrieved here so a caller that gave up logs no warning. + future.exception() + senders, self._handles = self._handles, {} + sources, self._sources = self._sources, {} + for sender in senders.values(): + sender._ended(reason) + for source in sources.values(): + source._ended(reason) + _call(self.on_exit, reason) + + +def _log_levels() -> list[int]: + """The parent's effective level of each forwarded logger.""" + return [ + logging.getLogger(name).getEffectiveLevel() + for name in FORWARDED_LOGGERS.values() + ] + + +def _fields(params: VideoParams | AudioParams) -> dict[str, Any]: + return dict(vars(params)) diff --git a/tests/test_media_worker.py b/tests/test_media_worker.py new file mode 100644 index 00000000..38741f1a --- /dev/null +++ b/tests/test_media_worker.py @@ -0,0 +1,112 @@ +"""Tests for pyhap.media.worker: the framing and lifecycle of the worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from pyhap.media import _worker # noqa: E402 +from pyhap.media.worker import ( # noqa: E402 + MAX_MESSAGE, + MediaWorker, + encode_message, + read_message, +) + +pytestmark = pytest.mark.timeout(30) + + +@pytest.fixture +async def worker() -> AsyncIterator[MediaWorker]: + """Yield a started worker and stop it afterwards.""" + started = MediaWorker() + await started.start() + yield started + await started.close() + + +def reader_of(data: bytes) -> asyncio.StreamReader: + """A stream reader that yields ``data`` and then EOF.""" + reader = asyncio.StreamReader() + reader.feed_data(data) + reader.feed_eof() + return reader + + +async def test_framing_round_trip() -> None: + """Verify a message and its binary part survive the framing.""" + data = encode_message({"event": "audio", "timestamp": 960}, b"\x00opus") + assert await read_message(reader_of(data)) == ( + {"event": "audio", "timestamp": 960}, + b"\x00opus", + ) + + +async def test_framing_rejects_oversized() -> None: + """Verify neither side accepts a message over the size limit.""" + with pytest.raises(ValueError, match="too large"): + encode_message({}, bytes(MAX_MESSAGE)) + header = (MAX_MESSAGE).to_bytes(4, "big") + (1).to_bytes(4, "big") + with pytest.raises(ValueError, match="too large"): + await read_message(reader_of(header)) + + +async def test_framing_rejects_non_object() -> None: + """Verify a frame must hold a JSON object.""" + data = (2).to_bytes(4, "big") + bytes(4) + b"[]" + with pytest.raises(ValueError, match="object"): + await read_message(reader_of(data)) + + +async def test_lifecycle(worker: MediaWorker) -> None: + """Verify start is idempotent, close stops the process and use restarts it.""" + pid = worker.pid + await worker.start() + assert worker.pid == pid + await worker.close() + assert not worker.running + await worker.close() + await worker.start() + assert worker.running + assert worker.pid != pid + + +def kill(worker: MediaWorker) -> None: + """Crash the worker process.""" + assert worker._process is not None + worker._process.kill() + + +class Output: + """Collects what the worker side writes to its output pipe.""" + + def __init__(self) -> None: + """Start empty.""" + self.data = b"" + + def is_closing(self) -> bool: + """The pipe stays open.""" + return False + + def write(self, data: bytes) -> None: + """Keep the frame.""" + self.data += data + + +async def unserializable(message: dict[str, Any]) -> dict[str, Any]: + """A request handler whose result cannot be encoded.""" + return {"sdp": object()} + + +async def test_unencodable_answer_becomes_an_error() -> None: + """Verify the worker answers with an error when its result cannot be sent.""" + output = Output() + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server._op_open_source = unserializable # type: ignore[method-assign] + await server._respond({"id": 7, "op": "open_source"}) + message, _blob = await read_message(reader_of(output.data)) + assert message["id"] == 7 + assert message["error"].startswith("Answer not sent") From dd42ed5ba0d27f1fc0704fcd93c774db10c1f13f Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:30:52 +0100 Subject: [PATCH 38/56] Keep reading ffmpeg's output in chunks so status lines can't block it ffmpeg ends its status line with a carriage return. Reading stderr line by line failed after 64 KiB without a newline, nobody drained the pipe any more and ffmpeg blocked. The delegate now starts ffmpeg without banner and status, reads its output in chunks and splits it on both line ends. A failing reader no longer keeps the watcher from ending the session. --- pyhap/camera_multi_tier.py | 46 ++++++++++++++++++++++++++------- tests/conftest.py | 30 ++++++++++++++++----- tests/test_camera_multi_tier.py | 34 +++++++++++++++++++++++- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index 269833de..8f4b0f55 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -595,6 +595,10 @@ def _answer(session: MultiTierSession) -> hksv3.SetupEndpointsResponse: ) +# Global options of FFmpegMultiTierDelegate, before the input: no banner, no +# status line, only warnings on stderr. +FFMPEG_GLOBAL_OPTIONS = ("-hide_banner", "-nostats", "-loglevel", "warning") + # Output arguments of FFmpegMultiTierDelegate, each formatted with the # session values. Video is copied, audio encoded to 16 kHz mono Opus in # 20 ms frames, which ffmpeg sends with the 48 kHz RTP clock. Video goes out @@ -620,7 +624,8 @@ class FFmpegMultiTierDelegate: """Streams each session with its own ffmpeg process. Pass it as the ``delegate`` of the ``multi_tier`` option. The command is - ``ffmpeg``, the ``ffmpeg_input`` arguments, :data:`FFMPEG_VIDEO_OUTPUT` + ``ffmpeg``, :data:`FFMPEG_GLOBAL_OPTIONS`, the ``ffmpeg_input`` arguments, + :data:`FFMPEG_VIDEO_OUTPUT` and, if the controller wants audio, :data:`FFMPEG_AUDIO_OUTPUT`. It is an argument list that no shell sees. @@ -640,7 +645,7 @@ class FFmpegMultiTierDelegate: def __init__( self, ffmpeg_input: Sequence[str], ffmpeg: str = "ffmpeg", audio: bool = True ) -> None: - self.argv = (ffmpeg, *ffmpeg_input) + self.argv = (ffmpeg, *FFMPEG_GLOBAL_OPTIONS, *ffmpeg_input) self.audio = audio async def handle_prepare(self, session: MultiTierSession) -> None: @@ -725,7 +730,9 @@ async def handle_end(self, session: MultiTierSession) -> None: with contextlib.suppress(ProcessLookupError): process.kill() await process.wait() - await session.data.pop("watcher") + watcher = session.data.pop("watcher") + with contextlib.suppress(Exception): + await watcher def _redact_command(cmd: list[str]) -> str: @@ -772,20 +779,41 @@ async def _watch( ) -> None: """Log ffmpeg's output and end the session with ffmpeg.""" output = asyncio.ensure_future(_log_output(session, process)) - code = await process.wait() - await output + try: + code = await process.wait() + with contextlib.suppress(Exception): + await output + finally: + output.cancel() if session.data.get("process") is process: logger.warning("[%s] Stream process exited with %s", session.id, code) session.end("the stream process exited") +# ffmpeg ends status lines with "\r", so the output is read in chunks, not +# lines; a line longer than this is logged in pieces. +_OUTPUT_CHUNK = 4096 + + async def _log_output( session: MultiTierSession, process: asyncio.subprocess.Process ) -> None: - # Reading the output also keeps the pipe from filling up. + # Reading the output also keeps the pipe from filling up and ffmpeg from + # blocking, so it keeps reading until ffmpeg closes it. if process.stderr is None: return - async for line in process.stderr: - # Token based, user info with a raw space is not covered. - text = _redact_urls(line.decode(errors="replace").rstrip()) + pending = b"" + while chunk := await process.stderr.read(_OUTPUT_CHUNK): + *lines, pending = re.split(rb"[\r\n]", pending + chunk) + if len(pending) >= _OUTPUT_CHUNK: + lines.append(pending) + pending = b"" + for line in lines: + _log_line(session, line) + _log_line(session, pending) + + +def _log_line(session: MultiTierSession, line: bytes) -> None: + # Token based, user info with a raw space is not covered. + if text := _redact_urls(line.decode(errors="replace").strip()): logger.debug("[%s] %s", session.id, text) diff --git a/tests/conftest.py b/tests/conftest.py index 9ae1235b..35cc46e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -198,6 +198,23 @@ def _make(**options: Any) -> Camera: return _make +class _FFmpegStderr: + """The stderr pipe of a fake ffmpeg, it ends when the process exits.""" + + def __init__(self, data: bytes, exited: asyncio.Event) -> None: + self._data = data + self._exited = exited + + async def read(self, n: int = -1) -> bytes: + if not self._data: + await self._exited.wait() + return b"" + size = len(self._data) if n < 0 else n + chunk, self._data = self._data[:size], self._data[size:] + await asyncio.sleep(0) + return chunk + + class _FFmpegProcess: """An ffmpeg process that runs until it is terminated or exits.""" @@ -205,15 +222,10 @@ def __init__(self, *lines: bytes) -> None: self.pid = 42 self.returncode: int | None = None self._exited = asyncio.Event() - self.stderr = self._stderr(lines) + self.stderr = _FFmpegStderr(b"".join(lines), self._exited) self.terminate = MagicMock(side_effect=lambda: self.exit(-15)) self.kill = MagicMock(side_effect=lambda: self.exit(-9)) - async def _stderr(self, lines: tuple[bytes, ...]) -> AsyncIterator[bytes]: - for line in lines: - yield line - await self._exited.wait() - def exit(self, code: int) -> None: self.returncode = code self._exited.set() @@ -223,6 +235,12 @@ async def wait(self) -> int: return cast(int, self.returncode) +@pytest.fixture +def ffmpeg_process_factory() -> type[_FFmpegProcess]: + """Make fake ffmpeg processes with the given output.""" + return _FFmpegProcess + + @pytest.fixture def ffmpeg_process() -> _FFmpegProcess: """A running ffmpeg process, its output has a frame and an input line.""" diff --git a/tests/test_camera_multi_tier.py b/tests/test_camera_multi_tier.py index d6a59aab..9612bd6f 100644 --- a/tests/test_camera_multi_tier.py +++ b/tests/test_camera_multi_tier.py @@ -13,6 +13,7 @@ from pyhap import hksv3, tlv from pyhap.camera import SETUP_ADDR_INFO, SETUP_SRTP_PARAM, SETUP_TYPES, Camera from pyhap.camera_multi_tier import ( + FFMPEG_GLOBAL_OPTIONS, FFmpegMultiTierDelegate, MultiTierController, MultiTierSession, @@ -978,7 +979,8 @@ async def test_ffmpeg_command(stream_camera: Callable[..., Camera]) -> None: cmd = delegate.command(session, HIGH, hksv3.DEFAULT_AUDIO_TIER) await camera.stop() - assert cmd[:3] == ["ffmpeg", *FFMPEG_INPUT] + assert cmd[:7] == ["ffmpeg", *FFMPEG_GLOBAL_OPTIONS, *FFMPEG_INPUT] + assert "-nostats" in cmd assert cmd[cmd.index("-ssrc") + 1] == str(session.video_ssrc) assert cmd[cmd.index("-srtp_out_params") + 1] == to_base64_str(KEY + SALT) assert cmd[cmd.index("-payload_type") + 1] == "99" @@ -1075,6 +1077,36 @@ async def test_ffmpeg_exit_ends_the_session( await camera.stop() +async def test_ffmpeg_status_lines_keep_the_pipe_drained( + stream_camera: Callable[..., Camera], + ffmpeg_process_factory: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test more than 64 KiB of "\\r" status lines are read and logged.""" + status = b"".join(b"frame=%d fps=30\r" % i for i in range(5000)) + process = ffmpeg_process_factory(status, b"last\n") + assert len(status) > 65536 + camera, _, _ = await _ffmpeg_session(stream_camera) + with ( + patch("asyncio.create_subprocess_exec", return_value=process), + caplog.at_level(logging.DEBUG, logger="pyhap.camera_multi_tier"), + ): + assert await _streaming_control(camera) == 0 + while process.stderr._data: + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert "frame=4999 fps=30" in caplog.text + assert "last" in caplog.text + process.exit(1) + for _ in range(100): + if not _controller(camera).sessions: + break + await asyncio.sleep(0) + assert _controller(camera).sessions == {} + await camera.stop() + + async def test_ffmpeg_announces_the_rtcp_monitor( stream_camera: Callable[..., Camera], ) -> None: From b648961f8150d60743608bce158c91fcbf3fe50e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:30:57 +0100 Subject: [PATCH 39/56] Test that stop_stream kills ffmpeg after the timeout --- tests/test_camera.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index 9d81af8b..2036c683 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -6,7 +6,7 @@ import socket import struct from typing import Any, cast -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from uuid import UUID import pytest @@ -420,3 +420,22 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: ): assert await acc.start_stream(session_info, safe_config) is True exec_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_stop_stream_kills_after_timeout(mock_driver: Any) -> None: + """Verify stop_stream kills ffmpeg when it does not exit in time.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + process = Mock() + process.communicate = AsyncMock(side_effect=TimeoutError) + process.wait = AsyncMock(return_value=0) + session_info = { + "id": UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1"), + "process": process, + } + + await acc.stop_stream(session_info) + + process.terminate.assert_called_once_with() + process.kill.assert_called_once_with() + process.wait.assert_awaited_once_with() From 2e0018828bdfef72cbcf18621566e61b54de2af3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:32:57 +0100 Subject: [PATCH 40/56] Don't tell subscribers the request of a write with r client_update_value takes notify=False for a write response request on a readable characteristic, so ev subscribers don't get a request that only the writer may read, e.g. a WiFi configuration with a key. HAPValue moves to pyhap.characteristic next to the formats. --- pyhap/accessory_driver.py | 20 ++++++++++++-------- pyhap/characteristic.py | 15 ++++++++++++--- tests/test_accessory_driver.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 387ba419..08e3e817 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -36,7 +36,12 @@ from pyhap import util from pyhap.accessory import Accessory, Bridge, get_topic -from pyhap.characteristic import PROP_PERMISSIONS, Characteristic, CharacteristicError +from pyhap.characteristic import ( + PROP_PERMISSIONS, + Characteristic, + CharacteristicError, + HAPValue, +) from pyhap.const import ( HAP_PERMISSION_NOTIFY, HAP_PERMISSION_READ, @@ -87,9 +92,6 @@ MAX_PREPARED_WRITES_PER_CLIENT = 16 MAX_PREPARED_WRITE_TTL_SECONDS = 30.0 -# A characteristic value as HAP JSON carries it. -HAPValue = bool | int | float | str | None - def _stored_value(char: Characteristic, value: HAPValue) -> HAPValue: """Return the value ``client_update_value`` stores for ``value``.""" @@ -100,10 +102,12 @@ def _stored_value(char: Characteristic, value: HAPValue) -> HAPValue: return stored -def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]: +def _wrap_char_setter( + char: Any, value: Any, client_addr: Any, notify: bool = True +) -> tuple[int, Any]: """Process an characteristic setter callback trapping and logging all exceptions.""" try: - response = char.client_update_value(value, client_addr) + response = char.client_update_value(value, client_addr, notify=notify) except Exception: logger.exception( "%s: Error while setting characteristic %s to %s", @@ -1124,7 +1128,7 @@ def set_characteristics( ) # A new write replaces a former response of this connection. The # request of a readable write response characteristic is not - # shared, whatever the setter does, except to ev subscribers. + # shared, whatever the setter does, also not with ev subscribers. if value is not None: self._write_responses.get(client_addr, {}).pop(char, None) private_write = ( @@ -1139,7 +1143,7 @@ def set_characteristics( if value is not None: set_result, set_result_value = _wrap_char_setter( - char, value, client_addr + char, value, client_addr, notify=not private_write ) # A write with ``r`` to a readable characteristic is a request, # e.g. a Setup Endpoints offer with the writer's SRTP keys. Only diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index a0b09a69..773aa57f 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -47,6 +47,9 @@ def _setter_wants_client_addr(setter: Callable[..., Any]) -> bool: # ### HAP Format ### +# A characteristic value as HAP JSON carries it. +HAPValue = bool | int | float | str | None + HAP_FORMAT_BOOL = "bool" HAP_FORMAT_INT = "int" HAP_FORMAT_FLOAT = "float" @@ -394,11 +397,17 @@ def set_value(self, value: Any, should_notify: bool = True) -> None: self.value = None def client_update_value( - self, value: Any, sender_client_addr: tuple[str, int] | None = None + self, + value: Any, + sender_client_addr: tuple[str, int] | None = None, + *, + notify: bool = True, ) -> Any: """Called from broker for value change in Home app. - Change self.value to value and call callback. + Change self.value to value and call callback. With ``notify`` False, + subscribers are not told about the written value, e.g. for a request + that only the writing connection may read back. """ original_value = value if not self._always_null or original_value is not None: @@ -421,7 +430,7 @@ def client_update_value( else: response = self.setter_callback(value) changed = self._value != previous_value - if changed: + if changed and notify: self.notify(sender_client_addr) if self._always_null: self.value = None diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index f7442732..13a5d471 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -880,6 +880,34 @@ def _setter(value: int) -> int: assert _read(driver, char_id, OTHER_CONTROLLER) == 42 +@pytest.mark.parametrize( + ("write_response", "notified"), + [ + pytest.param(True, [], id="private"), + pytest.param(False, [5], id="plain"), + ], +) +def test_private_write_not_notified( + driver: AccessoryDriver, write_response: bool, notified: list[int] +) -> None: + """Subscribers are not told the request of a write with ``r``.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "ev", "wr"], False) + char.getter_callback = None + char.setter_callback = MagicMock(return_value=7) + published = [] + with patch.object( + acc, "publish", side_effect=lambda value, *_: published.append(value) + ): + _write( + driver, + acc, + char, + **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: write_response}, + ) + + assert published == notified + + @pytest.mark.parametrize("reader", [WRITER, OTHER_CONTROLLER], ids=["writer", "other"]) @pytest.mark.parametrize( "make_setter", From d130cfbc3a913d89fc64f0167602eb0715cb56fb Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:33:52 +0100 Subject: [PATCH 41/56] Type the timed write pid and ignore a pid that is not an int A pid of another type, e.g. a list, raised a TypeError instead of answering -70410. --- pyhap/accessory_driver.py | 5 ++++- tests/test_timed_write.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index e33f39a5..8651c9a4 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1247,8 +1247,11 @@ def set_characteristics( return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None - def _take_prepared_write(self, pid: Any, client_addr: tuple[str, int]) -> bool: + def _take_prepared_write(self, pid: int, client_addr: tuple[str, int]) -> bool: """Consume the prepared write ``pid`` and return whether it is still valid.""" + if not isinstance(pid, int): + # Controller input; a list or dict is not even hashable. + return False expires_at = self.prepared_writes.get(client_addr, {}).pop(pid, None) return expires_at is not None and time.monotonic() <= expires_at diff --git a/tests/test_timed_write.py b/tests/test_timed_write.py index cac75f8b..700a4d37 100644 --- a/tests/test_timed_write.py +++ b/tests/test_timed_write.py @@ -100,13 +100,13 @@ def test_prepared_write_is_applied( @pytest.mark.parametrize( ("write_pid", "delay"), - [(8, 0.0), (7, 2.6)], - ids=["unknown_pid", "expired_pid"], + [(8, 0.0), (7, 2.6), ([7], 0.0)], + ids=["unknown_pid", "expired_pid", "unhashable_pid"], ) def test_unprepared_write_is_ignored( driver: AccessoryDriver, door: WritableChar, - write_pid: int, + write_pid: Any, delay: float, ) -> None: """Test a write with an unknown or expired pid is ignored as a whole.""" From 4fcf261ada21da5b1e057a17c54d08a5c4076129 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:33:55 +0100 Subject: [PATCH 42/56] Bound the SFrame keys and SDP m-line indexes controllers send An SFrame key must have 16 to 64 bytes, and an m-line index decodes only up to the 16 bits it encodes with. --- pyhap/hksv3.py | 20 ++++++++++++++------ tests/test_hksv3.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pyhap/hksv3.py b/pyhap/hksv3.py index 77b470ab..67b8b7d7 100644 --- a/pyhap/hksv3.py +++ b/pyhap/hksv3.py @@ -740,6 +740,9 @@ class WebRTCStreamingCommand(IntEnum): MAX_ICE_CANDIDATES = 64 MAX_ICE_CANDIDATE_SIZE = 1024 MAX_SFRAME_KEY_CHANGES = 16 +# Key sizes of an SFrame key: AES-128 to AES-256 with room for HMAC keys. +MIN_SFRAME_KEY_SIZE = 16 +MAX_SFRAME_KEY_SIZE = 64 def _to_str(data: bytes) -> str: @@ -813,11 +816,10 @@ def decode(cls, data: bytes) -> IceCandidate: f"ICE candidate of {len(data)} bytes, at most {MAX_ICE_CANDIDATE_SIZE}" ) fields = _fields(data) - return cls( - _to_str(_required(fields, 1)), - _optional(fields, 2, _to_str), - _optional_int(fields, 3), - ) + index = _optional_int(fields, 3) + if index is not None and index > 0xFFFF: + raise ValueError(f"SDP m-line index {index} is out of range") + return cls(_to_str(_required(fields, 1)), _optional(fields, 2, _to_str), index) def _encode_candidates(candidates: Iterable[IceCandidate]) -> bytes: @@ -849,7 +851,13 @@ def encode(self) -> bytes: @classmethod def decode(cls, data: bytes) -> SFrameKey: fields = _fields(data) - return cls(_required(fields, 1), _to_int(_required(fields, 2))) + key = _required(fields, 1) + if not MIN_SFRAME_KEY_SIZE <= len(key) <= MAX_SFRAME_KEY_SIZE: + raise ValueError( + f"SFrame key of {len(key)} bytes, {MIN_SFRAME_KEY_SIZE} to" + f" {MAX_SFRAME_KEY_SIZE}" + ) + return cls(key, _to_int(_required(fields, 2))) def generate_sframe_key() -> SFrameKey: diff --git a/tests/test_hksv3.py b/tests/test_hksv3.py index 461b9005..83e3a4f7 100644 --- a/tests/test_hksv3.py +++ b/tests/test_hksv3.py @@ -903,6 +903,21 @@ def test_webrtc_invalid(decode: Callable[[bytes], object], data: str) -> None: decode(bytes.fromhex(data)) +@pytest.mark.parametrize("size", [0, 15, 65]) +def test_sframe_key_size(size: int) -> None: + """Test a key outside 16 to 64 bytes is refused.""" + with pytest.raises(ValueError, match="SFrame key"): + SFrameKey.decode(SFrameKey(b"\xaa" * size, 1).encode()) + + +def test_ice_candidate_mline_index_range() -> None: + """Test the m-line index decodes only what encodes, up to 0xFFFF.""" + candidate = IceCandidate("a", "0", 0xFFFF) + assert IceCandidate.decode(candidate.encode()) == candidate + with pytest.raises(ValueError, match="m-line"): + IceCandidate.decode(bytes.fromhex("010161 0303 000001")) + + def test_generate_sframe_key() -> None: """Test generated keys have 32 random bytes and a 64-bit key ID.""" first = hksv3.generate_sframe_key() From 2374093ed410d17c660798766ab08ec1b5f31374 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:33:55 +0100 Subject: [PATCH 43/56] Tighten multi-tier and WebRTC session handling - A start keeps the setup timer from firing and restores the controller SSRCs if it fails. - A reoffer answer may only carry an SFrame key for a session with SFrame. - The ffmpeg delegate sends audio from a free port instead of the controller's. - Delegates are told how sessions stay alive: keep_alive for multi-tier, session.end() on media failure for WebRTC. - Client addresses are typed, the shared stream helpers are internal, and a session of another connection is logged at debug level. --- pyhap/accessory_driver.py | 10 +++-- pyhap/camera.py | 4 +- pyhap/camera_multi_tier.py | 65 +++++++++++++++++++++++++-------- pyhap/camera_stream.py | 28 +++++++------- pyhap/camera_webrtc.py | 62 ++++++++++++++++++++----------- tests/test_camera_multi_tier.py | 45 ++++++++++++++++++++++- tests/test_camera_stream.py | 34 +++++++++-------- tests/test_camera_webrtc.py | 17 +++++++++ 8 files changed, 193 insertions(+), 72 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 74fc9e99..e9e51746 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1098,15 +1098,19 @@ def derive_session_key( return None return connection.derive_key(salt, info) - def local_address(self, client_addr: Any, ipv6: bool) -> str | None: + def local_address( + self, client_addr: tuple[str, int] | None, ipv6: bool + ) -> str | None: """The accessory's address on a controller's connection. Returns None without such a connection or if its address is not of the asked IP version. An IPv4 address mapped into IPv6 counts as IPv4, a scope ID is dropped. """ - connection = self.http_server.connections.get(client_addr) - transport = getattr(connection, "transport", None) + connection = ( + self.http_server.connections.get(client_addr) if client_addr else None + ) + transport = connection.transport if connection else None sockname = transport.get_extra_info("sockname") if transport else None if not sockname: return None diff --git a/pyhap/camera.py b/pyhap/camera.py index f25246ab..25ee6214 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -873,7 +873,9 @@ def _update_stream_status(self) -> None: for controller in self._stream_controllers(): controller.update_status() - def stream_address_for(self, client_addr: Any, is_ipv6: bool) -> str | None: + def stream_address_for( + self, client_addr: tuple[str, int] | None, is_ipv6: bool + ) -> str | None: """The address to stream from to a controller, in the given IP version. The accessory's address on the controller's connection, else the diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index 8f4b0f55..cf0c7e24 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -20,19 +20,20 @@ import logging import re import secrets +import socket from typing import TYPE_CHECKING, Any, Protocol, cast from pyhap import hksv3 from pyhap.camera import parse_setup_address from pyhap.camera_stream import ( DEFAULT_HOOK_TIMEOUT, - HookFailed, SessionState, StreamController, StreamSession, - check_hook_timeout, - config_from_option, - failure_status, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, ) from pyhap.util import base64_to_bytes, to_base64_str @@ -167,6 +168,11 @@ class MultiTierDelegate(Protocol): Each hook may raise :class:`pyhap.camera_stream.StreamError` to answer with a status, any other exception answers with an error. A hook that takes longer than ``hook_timeout`` is cancelled and its session ends. + + With the default ``idle_timeout`` a started session ends after 30 s unless + the delegate calls :meth:`MultiTierSession.keep_alive`, e.g. for each + RTCP packet of the controller; :class:`RtcpMonitor` does that for a + delegate that cannot see the controller's RTCP itself. """ async def handle_prepare(self, session: MultiTierSession) -> None: @@ -241,10 +247,10 @@ def from_option(cls, value: MultiTierConfig | Mapping[str, Any]) -> MultiTierCon :raises ValueError: For an unknown key. """ - return config_from_option(cls, value, "multi_tier") + return _config_from_option(cls, value, "multi_tier") def __post_init__(self) -> None: - check_hook_timeout(self.hook_timeout) + _check_hook_timeout(self.hook_timeout) if self.max_sessions < MULTI_TIER_MIN_SESSIONS: raise ValueError( f"multi_tier max_sessions must be at least {MULTI_TIER_MIN_SESSIONS}" @@ -359,7 +365,7 @@ async def _release_hook(self, session: MultiTierSession) -> None: await self._delegate.handle_end(session) async def _set_setup_endpoints( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> Any: """Set up a session, only the writing connection reads the answer.""" deadline = self._deadline() @@ -378,7 +384,7 @@ async def _set_setup_endpoints( async def _setup( self, request: hksv3.SetupEndpoints, - client_addr: Any, + client_addr: tuple[str, int] | None, deadline: float, ) -> MultiTierSession | hksv3.SetupEndpointsStatus: session_id = request.session_id @@ -469,8 +475,8 @@ async def _setup( end_on_failure=True, first=True, ) - except HookFailed as err: - return failure_status(hksv3.SetupEndpointsStatus, err.status, error) + except _HookFailed as err: + return _failure_status(hksv3.SetupEndpointsStatus, err.status, error) session.state = SessionState.PREPARED self._set_timer( session, @@ -481,7 +487,7 @@ async def _setup( return session async def _set_streaming_control( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> str: """Start, change or end a stream, the write response is the status.""" deadline = self._deadline() @@ -499,7 +505,7 @@ async def _set_streaming_control( async def _streaming_control( self, request: hksv3.RTPStreamingControl, - client_addr: Any, + client_addr: tuple[str, int] | None, deadline: float, ) -> hksv3.RTPStreamingStatus: status = hksv3.RTPStreamingStatus @@ -522,19 +528,32 @@ async def _streaming_control( tiers = self._tiers(request) if tiers is None: return status.ERROR + previous_ssrcs = session.controller_video_ssrc, session.controller_audio_ssrc session.controller_video_ssrc = request.video_ssrc session.controller_audio_ssrc = request.audio_ssrc started = session.state is SessionState.STARTED if started: hook = self._delegate.handle_reconfigure(session, *tiers) else: + # The setup timer must not end the session while it starts. + self._cancel_timer(session) hook = self._delegate.handle_start(session, *tiers) try: await self._run_hook( session, "reconfigure" if started else "start", hook, deadline ) - except HookFailed as err: - return failure_status(status, err.status, status.ERROR) + except _HookFailed as err: + session.controller_video_ssrc, session.controller_audio_ssrc = ( + previous_ssrcs + ) + if not session.ended and session.state is SessionState.PREPARED: + self._set_timer( + session, + self.config.setup_timeout, + "Not started in time", + SessionState.PREPARED, + ) + return _failure_status(status, err.status, status.ERROR) session.video_tier, session.audio_tier = tiers session.state = SessionState.STARTED self.keep_alive(session) @@ -649,8 +668,12 @@ def __init__( self.audio = audio async def handle_prepare(self, session: MultiTierSession) -> None: - """Listen for the controller's RTCP on the announced video port.""" + """Listen for the controller's RTCP on the announced video port. + + ffmpeg sends the audio from a free port, which the answer announces. + """ session.data["rtcp_monitor"] = await open_rtcp_monitor(session) + session.accessory_audio_port = _free_udp_port(session.is_ipv6) def command( self, @@ -735,6 +758,18 @@ async def handle_end(self, session: MultiTierSession) -> None: await watcher +def _free_udp_port(ipv6: bool) -> int: + """A UDP port that is free now, for ffmpeg to bind. + + Two sessions of controllers that announced the same port would otherwise + both bind it. + """ + family = socket.AF_INET6 if ipv6 else socket.AF_INET + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.bind(("::" if ipv6 else "0.0.0.0", 0)) # noqa: S104 + return int(sock.getsockname()[1]) + + def _redact_command(cmd: list[str]) -> str: """Join the command for logging, without the SRTP keys and input secrets.""" return " ".join( diff --git a/pyhap/camera_stream.py b/pyhap/camera_stream.py index 24701593..73ce1bad 100644 --- a/pyhap/camera_stream.py +++ b/pyhap/camera_stream.py @@ -134,7 +134,7 @@ def end(self, reason: str = "the camera ended it") -> None: controller._loop.call_soon(controller._end, self, reason) -class HookFailed(Exception): +class _HookFailed(Exception): """A hook failed, ``status`` is the one the delegate asked for, if any. Raised by :meth:`StreamController._run_hook` to the stream services. @@ -145,7 +145,7 @@ def __init__(self, status: int | None = None) -> None: self.status = status -def failure_status(status_type: type[_E], status: int | None, error: _E) -> _E: +def _failure_status(status_type: type[_E], status: int | None, error: _E) -> _E: """The status a failed request answers, ``error`` unless the delegate chose.""" try: chosen = status_type(status) @@ -154,13 +154,13 @@ def failure_status(status_type: type[_E], status: int | None, error: _E) -> _E: return chosen if chosen else error -def check_hook_timeout(hook_timeout: float) -> None: +def _check_hook_timeout(hook_timeout: float) -> None: """Make sure a hook leaves time to answer within the HAP response timeout.""" if not 0 < hook_timeout <= MAX_HOOK_TIMEOUT: raise ValueError(f"hook_timeout must be above 0 and at most {MAX_HOOK_TIMEOUT}") -def config_from_option(config_type: type[_C], value: Any, name: str) -> _C: +def _config_from_option(config_type: type[_C], value: Any, name: str) -> _C: """Build a config dataclass from an option dict, or take it as it is. :raises ValueError: For an unknown key or a value of another type. @@ -188,7 +188,7 @@ def __init__( max_sessions: int, max_sessions_per_connection: int, ) -> None: - check_hook_timeout(hook_timeout) + _check_hook_timeout(hook_timeout) self.camera = camera self.sensor = sensor self.hook_timeout = hook_timeout @@ -221,7 +221,7 @@ def _add(self, session: _S) -> None: session._controller = self self._sessions[session.id] = session - def _no_room(self, client_addr: Any) -> str | None: + def _no_room(self, client_addr: tuple[str, int] | None) -> str | None: """Why a new session of ``client_addr`` does not fit, None if it does.""" if self._closed: return f"The {self.kind} service is closed" @@ -234,14 +234,16 @@ def _no_room(self, client_addr: Any) -> str | None: return f"Too many {self.kind} sessions of the connection" return None - def _owned(self, session_id: UUID, client_addr: Any) -> _S | None: + def _owned( + self, session_id: UUID, client_addr: tuple[str, int] | None + ) -> _S | None: """Return the session if ``client_addr`` owns it. A session of another connection is unknown to it. """ session = self._sessions.get(session_id) if session is not None and session.client_addr != client_addr: - logger.warning("[%s] Not the controller of the session", session_id) + logger.debug("[%s] Not the controller of the session", session_id) return None return session @@ -260,12 +262,12 @@ async def _run_hook( ``first`` marks the hook that introduces the session to the delegate, which then releases it when it ends. - :raises HookFailed: If the hook failed or took too long, or the + :raises _HookFailed: If the hook failed or took too long, or the session ended meanwhile. """ if session.ended: hook.close() - raise HookFailed + raise _HookFailed if first: session._delegated = True task = self._loop.create_task(hook) @@ -282,9 +284,9 @@ async def _run_hook( if not done: logger.warning("[%s] The %s hook timed out", session.id, name) self._end(session, f"the {name} hook timed out") - raise HookFailed + raise _HookFailed if task.cancelled() or session.ended: - raise HookFailed + raise _HookFailed error = task.exception() if error is None: return task.result() @@ -296,7 +298,7 @@ async def _run_hook( logger.error("[%s] The %s hook failed", session.id, name, exc_info=error) if end_on_failure: self._end(session, f"the {name} hook failed") - raise HookFailed(error.status if isinstance(error, StreamError) else None) + raise _HookFailed(error.status if isinstance(error, StreamError) else None) def _background(self, coro: Coroutine[Any, Any, _T]) -> asyncio.Task[_T]: task = self._loop.create_task(coro) diff --git a/pyhap/camera_webrtc.py b/pyhap/camera_webrtc.py index f5510b84..774ad4a4 100644 --- a/pyhap/camera_webrtc.py +++ b/pyhap/camera_webrtc.py @@ -24,13 +24,13 @@ from pyhap import hksv3 from pyhap.camera_stream import ( DEFAULT_HOOK_TIMEOUT, - HookFailed, SessionState, StreamController, StreamSession, - check_hook_timeout, - config_from_option, - failure_status, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, ) from pyhap.util import base64_to_bytes, to_base64_str @@ -76,6 +76,11 @@ class WebRTCDelegate(Protocol): Each hook may raise :class:`pyhap.camera_stream.StreamError` to answer with a status, any other exception answers with an error. A hook that takes longer than ``hook_timeout`` is cancelled and its session ends. + + An answered session has no timeout of its own: remote viewers share the + home hub's connection, so its close ends nothing. The delegate must call + ``session.end()`` when the media connection fails, e.g. on ICE or consent + failure, or the session keeps one of the sensor's slots. """ async def handle_solicit_offer( @@ -153,10 +158,10 @@ def from_option(cls, value: WebRTCConfig | Mapping[str, Any]) -> WebRTCConfig: :raises ValueError: For an unknown key. """ - return config_from_option(cls, value, "webrtc") + return _config_from_option(cls, value, "webrtc") def __post_init__(self) -> None: - check_hook_timeout(self.hook_timeout) + _check_hook_timeout(self.hook_timeout) if self.max_sessions < WEBRTC_MIN_SESSIONS: raise ValueError( f"webrtc max_sessions must be at least {WEBRTC_MIN_SESSIONS}" @@ -276,7 +281,7 @@ async def _release_hook(self, session: WebRTCSession) -> None: await self._delegate.handle_end(session) async def _set_solicit_offer( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> str: deadline = self._deadline() request = _decode("Solicit Offer", hksv3.WebRTCSolicitOffer.decode, value) @@ -294,7 +299,10 @@ async def _set_solicit_offer( return to_base64_str(response.encode()) async def _solicit_offer( - self, options: hksv3.WebRTCOfferOptions, client_addr: Any, deadline: float + self, + options: hksv3.WebRTCOfferOptions, + client_addr: tuple[str, int] | None, + deadline: float, ) -> hksv3.WebRTCSolicitOfferResponse: error = hksv3.WebRTCSolicitOfferStatus.ERROR refusal = self.refusal() @@ -337,9 +345,9 @@ async def _solicit_offer( end_on_failure=True, first=True, ) - except HookFailed as err: + except _HookFailed as err: return hksv3.WebRTCSolicitOfferResponse( - failure_status(hksv3.WebRTCSolicitOfferStatus, err.status, error) + _failure_status(hksv3.WebRTCSolicitOfferStatus, err.status, error) ) if (offer.sframe is not None) != session.sframe: logger.error("[%s] The offer's SFrame key does not fit", session.id) @@ -355,7 +363,7 @@ async def _solicit_offer( ) async def _set_provide_answer( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> str: deadline = self._deadline() request = _decode("Provide Answer", hksv3.WebRTCProvideAnswer.decode, value) @@ -376,9 +384,9 @@ async def _set_provide_answer( ) try: await self._run_hook(session, "answer", hook, deadline, end_on_failure=True) - except HookFailed as err: + except _HookFailed as err: return _status( - session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + session.id, _failure_status(_STATUS, err.status, _STATUS.ERROR) ) # Only now, the expiry ends a session whose answer did not apply. self._cancel_timer(session) @@ -387,7 +395,7 @@ async def _set_provide_answer( return _status(session.id, _STATUS.SUCCESS) async def _set_streaming_control( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> str: deadline = self._deadline() request = _decode( @@ -404,7 +412,7 @@ async def _set_streaming_control( return _status(session.id, _STATUS.SUCCESS if ended else _STATUS.ERROR) def _active_session( - self, session_id: UUID, client_addr: Any + self, session_id: UUID, client_addr: tuple[str, int] | None ) -> WebRTCSession | hksv3.WebRTCStreamingStatus: """The session to renegotiate or update, else the status to answer.""" session = self._owned(session_id, client_addr) @@ -416,7 +424,9 @@ def _active_session( return _STATUS.BUSY return session - async def _set_reoffer(self, value: str, sender_client_addr: Any = None) -> str: + async def _set_reoffer( + self, value: str, sender_client_addr: tuple[str, int] | None = None + ) -> str: deadline = self._deadline() request = _decode("Reoffer", hksv3.WebRTCReoffer.decode, value) if request is None: @@ -426,7 +436,10 @@ async def _set_reoffer(self, value: str, sender_client_addr: Any = None) -> str: return to_base64_str(response.encode()) async def _reoffer( - self, request: hksv3.WebRTCReoffer, client_addr: Any, deadline: float + self, + request: hksv3.WebRTCReoffer, + client_addr: tuple[str, int] | None, + deadline: float, ) -> hksv3.WebRTCReofferResponse: session = self._active_session(request.session_id, client_addr) if not isinstance(session, WebRTCSession): @@ -436,16 +449,21 @@ async def _reoffer( ) try: answer = await self._run_hook(session, "reoffer", hook, deadline) - except HookFailed as err: + except _HookFailed as err: return hksv3.WebRTCReofferResponse( - session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + session.id, _failure_status(_STATUS, err.status, _STATUS.ERROR) + ) + if answer.sframe is not None and not session.sframe: + logger.error( + "[%s] The reoffer answer's SFrame key does not fit", session.id ) + return hksv3.WebRTCReofferResponse(session.id, _STATUS.ERROR) return hksv3.WebRTCReofferResponse( session.id, _STATUS.SUCCESS, answer.sdp, answer.sframe ) async def _set_update_session( - self, value: str, sender_client_addr: Any = None + self, value: str, sender_client_addr: tuple[str, int] | None = None ) -> str: deadline = self._deadline() request = _decode("Update Session", hksv3.WebRTCUpdateSession.decode, value) @@ -459,8 +477,8 @@ async def _set_update_session( ) try: await self._run_hook(session, "update session", hook, deadline) - except HookFailed as err: + except _HookFailed as err: return _status( - session.id, failure_status(_STATUS, err.status, _STATUS.ERROR) + session.id, _failure_status(_STATUS, err.status, _STATUS.ERROR) ) return _status(session.id, _STATUS.SUCCESS) diff --git a/tests/test_camera_multi_tier.py b/tests/test_camera_multi_tier.py index 9612bd6f..098d1d2c 100644 --- a/tests/test_camera_multi_tier.py +++ b/tests/test_camera_multi_tier.py @@ -719,6 +719,46 @@ async def test_start_fails( assert _session(camera).state is state +async def test_failed_start_keeps_the_former_ssrcs( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test a failed start leaves the controller SSRCs as they were.""" + multi_tier_delegate.handle_start.side_effect = StreamError(3) + camera = stream_camera() + await _setup(camera) + assert await _streaming_control(camera) == 3 + session = _session(camera) + assert session.controller_video_ssrc is None + assert session.controller_audio_ssrc is None + await camera.stop() + + +async def test_setup_timer_waits_for_a_running_start( + stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock +) -> None: + """Test the setup timeout does not end a session while it starts.""" + started = asyncio.Event() + release = asyncio.Event() + + async def _start(*_args: object) -> None: + started.set() + await release.wait() + + multi_tier_delegate.handle_start.side_effect = _start + camera = stream_camera( + multi_tier={"delegate": multi_tier_delegate, "setup_timeout": 0.01} + ) + await _setup(camera) + start = asyncio.ensure_future(_streaming_control(camera)) + await started.wait() + await asyncio.sleep(0.03) + release.set() + + assert await start == 0 + assert _session(camera).state is SessionState.STARTED + await camera.stop() + + async def test_start_while_preparing( stream_camera: Callable[..., Camera], multi_tier_delegate: AsyncMock ) -> None: @@ -991,7 +1031,8 @@ async def test_ffmpeg_command(stream_camera: Callable[..., Camera]) -> None: "-c:a", ] assert cmd[-1] == ( - "srtp://192.168.1.114:50002?rtcpport=50002&localrtpport=50002&pkt_size=188" + "srtp://192.168.1.114:50002?rtcpport=50002" + f"&localrtpport={session.accessory_audio_port}&pkt_size=188" ) assert "srtp://192.168.1.114:50000?rtcpport=50000&pkt_size=1190" in cmd @@ -1082,7 +1123,7 @@ async def test_ffmpeg_status_lines_keep_the_pipe_drained( ffmpeg_process_factory: Any, caplog: pytest.LogCaptureFixture, ) -> None: - """Test more than 64 KiB of "\\r" status lines are read and logged.""" + """Test more than 64 KiB of carriage return status lines are read.""" status = b"".join(b"frame=%d fps=30\r" % i for i in range(5000)) process = ffmpeg_process_factory(status, b"last\n") assert len(status) > 65536 diff --git a/tests/test_camera_stream.py b/tests/test_camera_stream.py index 7bc95e25..9fe9bb31 100644 --- a/tests/test_camera_stream.py +++ b/tests/test_camera_stream.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass +import logging from typing import Any from unittest.mock import AsyncMock from uuid import uuid4 @@ -12,14 +13,14 @@ from pyhap import hksv3 from pyhap.camera_stream import ( MAX_HOOK_TIMEOUT, - HookFailed, SessionState, StreamController, StreamError, StreamSession, - check_hook_timeout, - config_from_option, - failure_status, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, ) CLIENT = ("192.168.1.114", 50000) @@ -79,7 +80,7 @@ async def test_hook_timeout_ends_the_session( session = add_session(CLIENT) deadline = session_controller._deadline() - with pytest.raises(HookFailed) as failed: + with pytest.raises(_HookFailed) as failed: await session_controller._run_hook( session, "test", _never(), deadline, first=True ) @@ -109,7 +110,7 @@ async def test_hook_errors( """Test a delegate's status is kept, other errors have none.""" session = add_session(CLIENT) - with pytest.raises(HookFailed) as failed: + with pytest.raises(_HookFailed) as failed: await session_controller._run_hook( session, "test", _raise(error), session_controller._deadline() ) @@ -126,7 +127,7 @@ async def test_failed_hook_may_end_the_session( """Test a failed first hook ends the session and still releases it.""" session = add_session(CLIENT) - with pytest.raises(HookFailed): + with pytest.raises(_HookFailed): await session_controller._run_hook( session, "test", @@ -192,7 +193,7 @@ async def test_end_waits_for_cancelled_hooks( assert await session_controller.end_session(session.id) is True session_release.assert_awaited_once_with(session) - with pytest.raises(HookFailed): + with pytest.raises(_HookFailed): await hook @@ -243,7 +244,7 @@ async def test_hook_of_ended_session( session_controller._end(session, "test") hook = _hook() - with pytest.raises(HookFailed): + with pytest.raises(_HookFailed): await session_controller._run_hook( session, "test", hook, session_controller._deadline() ) @@ -288,6 +289,7 @@ async def test_ownership( ) -> None: """Test only the connection that set a session up finds it.""" session = add_session(CLIENT) + caplog.set_level(logging.DEBUG, logger="pyhap.camera_stream") assert session_controller._owned(session.id, CLIENT) is session assert session_controller._owned(session.id, OTHER) is None @@ -365,19 +367,19 @@ async def test_end_by_request_waits_until_the_deadline( def test_hook_timeout_leaves_time_to_answer(hook_timeout: float) -> None: """Test hooks must end before the HAP response timeout.""" with pytest.raises(ValueError, match="hook_timeout"): - check_hook_timeout(hook_timeout) + _check_hook_timeout(hook_timeout) def test_config_from_option() -> None: """Test options build a config, a config is taken as it is.""" config = _Config(max_sessions=6) - assert config_from_option(_Config, config, "multi_tier") is config - assert config_from_option(_Config, {"max_sessions": 6}, "x") == config + assert _config_from_option(_Config, config, "multi_tier") is config + assert _config_from_option(_Config, {"max_sessions": 6}, "x") == config with pytest.raises(ValueError, match="Unknown multi_tier option: start"): - config_from_option(_Config, {"start": 1}, "multi_tier") + _config_from_option(_Config, {"start": 1}, "multi_tier") with pytest.raises(ValueError, match="must be a dict"): - config_from_option(_Config, [], "multi_tier") + _config_from_option(_Config, [], "multi_tier") @pytest.mark.parametrize( @@ -387,7 +389,7 @@ def test_config_from_option() -> None: def test_failure_status(status: int | None, expected: int) -> None: """Test a delegate picks a known failure status, else the error.""" error = hksv3.RTPStreamingStatus.ERROR - assert failure_status(hksv3.RTPStreamingStatus, status, error) == expected + assert _failure_status(hksv3.RTPStreamingStatus, status, error) == expected async def test_session_end( @@ -447,7 +449,7 @@ async def test_release_after_a_stuck_hook( assert not finished.is_set() assert "while 1 cancelled hooks still run" in caplog.text session_release.assert_awaited_once_with(session) - with pytest.raises(HookFailed): + with pytest.raises(_HookFailed): await request await finished.wait() diff --git a/tests/test_camera_webrtc.py b/tests/test_camera_webrtc.py index 88255b87..90fb3f49 100644 --- a/tests/test_camera_webrtc.py +++ b/tests/test_camera_webrtc.py @@ -537,6 +537,23 @@ async def test_reoffer( ) +async def test_reoffer_answer_with_unexpected_sframe_key( + stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock +) -> None: + """Test an SFrame key in the answer of a session without SFrame fails.""" + camera = stream_camera() + session = await _active(camera) + session.sframe = False + webrtc_delegate.handle_reoffer.return_value = hksv3.WebRTCReofferAnswer( + "v=0\r\n", KEY + ) + + response = await _reoffer(camera, session.id) + + assert response == hksv3.WebRTCReofferResponse(session.id, 3) + assert session.state is SessionState.ACTIVE + + async def test_update_session( stream_camera: Callable[..., Camera], webrtc_delegate: AsyncMock ) -> None: From 3a593d75a59d4570abcfea0fd58b3440b83441b6 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:34:46 +0100 Subject: [PATCH 44/56] Name the fields of a pending async write and keep write_response a bool A write asks for a response only with "r": true. --- pyhap/accessory_driver.py | 41 +++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 538cfe56..00c4d40d 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -31,7 +31,7 @@ import tempfile import threading import time -from typing import Any +from typing import Any, NamedTuple from zeroconf import ServiceInfo from zeroconf.asyncio import AsyncZeroconf @@ -172,9 +172,13 @@ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any: return HAP_SERVER_STATUS.SUCCESS -# A characteristic write whose setter is a coroutine: the result entry to -# complete, the coroutine, whether a write response was asked for and the char. -_PendingWrite = tuple[dict[str, Any], Coroutine[Any, Any, Any], bool, Characteristic] +class _PendingWrite(NamedTuple): + """A characteristic write whose setter returned a coroutine.""" + + result: dict[str, Any] # The result entry to complete. + coro: Coroutine[Any, Any, Any] + write_response: bool # Whether the controller asked for a write response. + char: Characteristic def _set_characteristics_response( @@ -1256,7 +1260,7 @@ def _run_characteristic_writes( # Only characteristics with the write response permission answer # a write with a value, and only when the controller asks for it. write_response = ( - query.get(HAP_REPR_WRITE_RESPONSE, False) + query.get(HAP_REPR_WRITE_RESPONSE) is True and HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] ) # A new write replaces a former response of this connection. The @@ -1288,7 +1292,9 @@ def _run_characteristic_writes( result: dict[str, Any] = {HAP_REPR_STATUS: set_result} if inspect.iscoroutine(set_result_value): - pending.append((result, set_result_value, write_response, char)) + pending.append( + _PendingWrite(result, set_result_value, write_response, char) + ) elif set_result_value is not None and write_response: result[HAP_REPR_VALUE] = self._store_write_response( char, set_result_value, client_addr @@ -1334,10 +1340,7 @@ async def _async_finish_set_characteristics( timeout: float, ) -> dict[str, Any] | None: """Wait for the coroutine setters, then build the response.""" - tasks = { - asyncio.ensure_future(coro): (result, write_response, char) - for result, coro, write_response, char in pending - } + tasks = {asyncio.ensure_future(write.coro): write for write in pending} try: done, not_done = await asyncio.wait(tasks, timeout=timeout) except asyncio.CancelledError: @@ -1346,32 +1349,32 @@ async def _async_finish_set_characteristics( raise for task in not_done: task.cancel() - result, _, char = tasks[task] + write = tasks[task] logger.warning( "%s: Timed out setting characteristic %s", client_addr, - char.display_name, + write.char.display_name, ) - result[HAP_REPR_STATUS] = HAP_SERVER_STATUS.OPERATION_TIMED_OUT + write.result[HAP_REPR_STATUS] = HAP_SERVER_STATUS.OPERATION_TIMED_OUT for task in done: - result, write_response, char = tasks[task] + write = tasks[task] try: outcome = task.result() except (Exception, asyncio.CancelledError): logger.exception( "%s: Error while setting characteristic %s", client_addr, - char.display_name, + write.char.display_name, ) - result[HAP_REPR_STATUS] = ( + write.result[HAP_REPR_STATUS] = ( HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE ) continue - if outcome is not None and write_response: + if outcome is not None and write.write_response: # A response queued when the connection closes is dropped by # the deferred cleanup in connection_lost. - result[HAP_REPR_VALUE] = self._store_write_response( - char, outcome, client_addr + write.result[HAP_REPR_VALUE] = self._store_write_response( + write.char, outcome, client_addr ) return _set_characteristics_response(results) From c2c1aeee4996a1be4fdaedef2683efabe75fa452 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:36:26 +0100 Subject: [PATCH 45/56] Keep the WebRTC SDP out of the media core Only the WebRTC sender needs SDP, so sdp.py and its tests come with it. The RTP clock rates and payload types move to rtp.py, which the source and the senders share. The test helpers are named after the media package. --- pyhap/media/__init__.py | 5 - pyhap/media/rtp.py | 7 + pyhap/media/sdp.py | 616 ------------------ pyhap/media/source.py | 3 +- pyhap/media/worker.py | 5 - tests/{webrtc_fakes.py => media_fakes.py} | 0 tests/{webrtc_helpers.py => media_helpers.py} | 32 - tests/test_media_ffmpeg_provider.py | 2 +- tests/test_media_hevc.py | 2 +- tests/test_media_sdp.py | 472 -------------- 10 files changed, 10 insertions(+), 1134 deletions(-) delete mode 100644 pyhap/media/sdp.py rename tests/{webrtc_fakes.py => media_fakes.py} (100%) rename tests/{webrtc_helpers.py => media_helpers.py} (74%) delete mode 100644 tests/test_media_sdp.py diff --git a/pyhap/media/__init__.py b/pyhap/media/__init__.py index a0209394..ccb49c7a 100644 --- a/pyhap/media/__init__.py +++ b/pyhap/media/__init__.py @@ -10,8 +10,6 @@ from typing import TYPE_CHECKING, Any -from .sdp import AudioParams, IceCandidateLike, VideoParams - class MediaError(Exception): """A media session cannot do what was asked.""" @@ -33,18 +31,15 @@ class MediaError(Exception): ) __all__ = [ - "AudioParams", "ChannelLease", "ChannelPull", "FFmpegInput", "FFmpegSourceProvider", - "IceCandidateLike", "MediaSource", "MediaSourceLike", "RemoteMediaSource", "SourceLease", "SourcePool", - "VideoParams", "MediaError", "MediaWorker", ] diff --git a/pyhap/media/rtp.py b/pyhap/media/rtp.py index b1f9721e..badd4396 100644 --- a/pyhap/media/rtp.py +++ b/pyhap/media/rtp.py @@ -9,6 +9,13 @@ import struct import time +# RTP clock rates of H.265 video and Opus audio (RFC 7798, RFC 7587). +VIDEO_CLOCK_RATE = 90000 +AUDIO_CLOCK_RATE = 48000 +# Dynamic payload types the HomeKit streams use for H.265 and Opus. +DEFAULT_VIDEO_PAYLOAD_TYPE = 99 +DEFAULT_AUDIO_PAYLOAD_TYPE = 110 + # Shortest interval between keyframe requests to a source. KEYFRAME_REQUEST_INTERVAL = 1.0 # An audio gap this long starts a new talkspurt (RTP marker bit). diff --git a/pyhap/media/sdp.py b/pyhap/media/sdp.py deleted file mode 100644 index 980faceb..00000000 --- a/pyhap/media/sdp.py +++ /dev/null @@ -1,616 +0,0 @@ -"""SDP for HomeKit WebRTC remote live view. - -The accessory is the offerer. Its offer is sendonly, BUNDLEs one H.265 video -and one Opus audio section, carries all ICE candidates (no trickle) and keeps -``a=setup:actpass`` so the controller picks the DTLS role. - -The video section offers H.265 as the only video codec and carries -``a=rid:1 send`` with ``max-width``, ``max-height``, ``max-fps`` and -``max-br`` but no RID RTP header extension. That matches the offer of -Scrypted's HomeKit WebRTC plugin (https://github.com/koush/scrypted, -``plugins/webrtc``), which is known to play remotely. - -The offer carries every ICE candidate. A controller may still send its -answer's candidates next to the SDP in HAP (RFC 8839 candidate strings with an -optional mid and m-line index, see :class:`IceCandidateLike`); they are merged -into the answer before it is parsed. At most :data:`MAX_CANDIDATES` candidates -are kept per media section. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Sequence -from dataclasses import dataclass, field -from itertools import pairwise -from typing import Protocol - -VIDEO_CLOCK_RATE = 90000 -AUDIO_CLOCK_RATE = 48000 -# The payload types HAP uses for the WebRTC stream tiers. -DEFAULT_VIDEO_PAYLOAD_TYPE = 99 -DEFAULT_AUDIO_PAYLOAD_TYPE = 110 -OPUS_PTIME = 20 -VIDEO_MID = "0" -AUDIO_MID = "1" -RID = "1" -DIRECTIONS = ("sendrecv", "sendonly", "recvonly", "inactive") -MAX_CANDIDATES = 32 -MAX_SDP_SIZE = 32 * 1024 -MAX_MEDIA_SECTIONS = 16 - - -class SdpError(ValueError): - """An SDP could not be parsed or does not fit the session.""" - - -class IceCandidateLike(Protocol): - """An ICE candidate as HAP carries it next to the SDP. - - ``candidate`` is the RFC 8839 candidate string, with or without the - ``candidate:`` prefix. - """ - - @property - def candidate(self) -> str: - """The candidate string.""" - - @property - def sdp_mid(self) -> str | None: - """The mid of the media section it belongs to.""" - - @property - def sdp_mline_index(self) -> int | None: - """The index of the media section it belongs to.""" - - -class VideoTierLike(Protocol): - """A HAP video stream tier; bit rates in kbps.""" - - @property - def width(self) -> int: - """Pixels.""" - - @property - def height(self) -> int: - """Pixels.""" - - @property - def frame_rate(self) -> int: - """Frames per second.""" - - @property - def target_bitrate(self) -> int: - """The average bit rate.""" - - @property - def peak_bitrate(self) -> int | None: - """The peak bit rate, if known.""" - - -@dataclass(frozen=True) -class VideoParams: - """The video stream tier the offer advertises. - - ``peak_bitrate_kbps`` goes into ``max-br``; ``target_bitrate_kbps`` (the - average) paces the sender and defaults to the peak. - """ - - width: int - height: int - fps: int - peak_bitrate_kbps: int - payload_type: int = DEFAULT_VIDEO_PAYLOAD_TYPE - target_bitrate_kbps: int | None = None - - @classmethod - def from_tier( - cls, tier: VideoTierLike, payload_type: int = DEFAULT_VIDEO_PAYLOAD_TYPE - ) -> VideoParams: - """Describe a HAP video tier; the peak falls back to the target.""" - return cls( - width=tier.width, - height=tier.height, - fps=tier.frame_rate, - peak_bitrate_kbps=tier.peak_bitrate or tier.target_bitrate, - payload_type=payload_type, - target_bitrate_kbps=tier.target_bitrate, - ) - - @property - def pacing_bitrate_kbps(self) -> int: - """The bit rate the pacer is based on.""" - return self.target_bitrate_kbps or self.peak_bitrate_kbps - - -@dataclass(frozen=True) -class AudioParams: - """The mono Opus audio stream; ``talkback`` makes the section sendrecv.""" - - payload_type: int = DEFAULT_AUDIO_PAYLOAD_TYPE - talkback: bool = False - - -@dataclass(frozen=True) -class LocalParams: - """Transport and stream identifiers of the local side.""" - - ice_ufrag: str - ice_pwd: str - fingerprint: str - cname: str - stream_id: str - video_ssrc: int - audio_ssrc: int | None = None - candidates: Sequence[str] = () - session_id: int = 0 - session_version: int = 2 - - -@dataclass -class MediaDescription: - """One parsed m-section.""" - - kind: str - port: int - protocol: str - formats: list[str] - mid: str | None = None - direction: str = "sendrecv" - rtpmap: dict[int, tuple[str, int, int | None]] = field(default_factory=dict) - fmtp: dict[int, str] = field(default_factory=dict) - ssrcs: dict[int, None] = field(default_factory=dict) - candidates: list[str] = field(default_factory=list) - rids: list[str] = field(default_factory=list) - ice_ufrag: str | None = None - ice_pwd: str | None = None - fingerprints: list[tuple[str, str]] = field(default_factory=list) - setup: str | None = None - - @property - def payload_types(self) -> list[int]: - """The numeric formats of an RTP section.""" - return [int(fmt) for fmt in self.formats if fmt.isascii() and fmt.isdigit()] - - def payload_type_for( - self, codec: str, clock_rate: int, channels: int | None = None - ) -> int | None: - """Return the first payload type mapped to a codec, if any.""" - for payload_type in self.payload_types: - mapping = self.rtpmap.get(payload_type) - if ( - mapping - and mapping[0].lower() == codec.lower() - and mapping[1] == clock_rate - and (channels is None or (mapping[2] or 1) == channels) - ): - return payload_type - return None - - -@dataclass -class SessionDescription: - """A parsed SDP with transport attributes resolved per section.""" - - media: list[MediaDescription] - bundle: list[str] = field(default_factory=list) - ice_lite: bool = False - - def transport(self) -> MediaDescription: - """Return the section whose transport the session uses. - - With BUNDLE that is the first section of the group, otherwise the - first section with a non-zero port. - """ - for media in self.media: - if media.port and (not self.bundle or media.mid == self.bundle[0]): - return media - raise SdpError("SDP has no usable media section") - - @property - def candidates(self) -> list[str]: - """Candidates of the transport sections, without duplicates.""" - seen: dict[str, None] = {} - bundle = set(self.bundle) - for media in self.media: - if media.port and (not bundle or media.mid in bundle): - seen.update(dict.fromkeys(media.candidates)) - return list(seen)[:MAX_CANDIDATES] - - -def _checked_size(sdp: str) -> str: - if len(sdp) > MAX_SDP_SIZE: - raise SdpError(f"SDP larger than {MAX_SDP_SIZE} characters") - return sdp - - -def _split_lines(sdp: str) -> list[str]: - return [line for line in sdp.replace("\r\n", "\n").split("\n") if line] - - -@dataclass -class _SessionAttributes: - ice_ufrag: str | None = None - ice_pwd: str | None = None - setup: str | None = None - fingerprints: list[tuple[str, str]] = field(default_factory=list) - - -def parse(sdp: str) -> SessionDescription: - """Parse an SDP; ICE and DTLS attributes fall back to session level. - - An SDP over :data:`MAX_SDP_SIZE` characters or with more than - :data:`MAX_MEDIA_SECTIONS` media sections is rejected. - """ - session = _SessionAttributes() - desc = SessionDescription(media=[]) - current: MediaDescription | None = None - for line in _split_lines(_checked_size(sdp)): - if line.startswith("m="): - if len(desc.media) == MAX_MEDIA_SECTIONS: - raise SdpError("Too many media sections") - current = _media_line(line) - desc.media.append(current) - continue - if not line.startswith("a="): - continue - name, _, value = line[2:].partition(":") - try: - if current is None: - _session_attribute(desc, session, name, value) - else: - _media_attribute(current, name, value) - except (ValueError, IndexError) as err: - raise SdpError(f"Invalid attribute: {line[:80]!r}") from err - for media in desc.media: - media.ice_ufrag = media.ice_ufrag or session.ice_ufrag - media.ice_pwd = media.ice_pwd or session.ice_pwd - media.setup = media.setup or session.setup - media.fingerprints = media.fingerprints or list(session.fingerprints) - return desc - - -def _media_line(line: str) -> MediaDescription: - parts = line[2:].split() - try: - return MediaDescription( - kind=parts[0], - port=int(parts[1].split("/")[0]), - protocol=parts[2], - formats=parts[3:], - ) - except (ValueError, IndexError) as err: - raise SdpError(f"Invalid m-line: {line[:80]!r}") from err - - -def _fingerprint(value: str) -> tuple[str, str]: - algorithm, _, digest = value.partition(" ") - return algorithm.lower(), digest.strip().upper() - - -def _session_attribute( - desc: SessionDescription, session: _SessionAttributes, name: str, value: str -) -> None: - if name == "group" and value.startswith("BUNDLE"): - desc.bundle = value.split()[1:] - elif name == "ice-lite": - desc.ice_lite = True - elif name == "ice-ufrag": - session.ice_ufrag = value.strip() - elif name == "ice-pwd": - session.ice_pwd = value.strip() - elif name == "setup": - session.setup = value.strip() - elif name == "fingerprint": - session.fingerprints.append(_fingerprint(value)) - - -def _media_attribute(media: MediaDescription, name: str, value: str) -> None: - if name == "mid": - media.mid = value.strip() - elif name in DIRECTIONS and not value: - media.direction = name - elif name == "rtpmap": - pt, _, codec = value.partition(" ") - codec_parts = codec.strip().split("/") - media.rtpmap[int(pt)] = ( - codec_parts[0], - int(codec_parts[1]) if len(codec_parts) > 1 else 0, - int(codec_parts[2]) if len(codec_parts) > 2 else None, - ) - elif name == "fmtp": - pt, _, params = value.partition(" ") - media.fmtp[int(pt)] = params.strip() - elif name == "ssrc": - media.ssrcs[int(value.split(maxsplit=1)[0])] = None - elif name == "candidate" and len(media.candidates) < MAX_CANDIDATES: - media.candidates.append(value.strip()) - elif name == "rid": - media.rids.append(value.strip()) - elif name == "ice-ufrag": - media.ice_ufrag = value.strip() - elif name == "ice-pwd": - media.ice_pwd = value.strip() - elif name == "setup": - media.setup = value.strip() - elif name == "fingerprint": - media.fingerprints.append(_fingerprint(value)) - - -def candidate_value(candidate: str) -> str: - """Strip ``a=`` and ``candidate:`` from a candidate string.""" - value = candidate.strip() - value = value.removeprefix("a=") - return value.removeprefix("candidate:") - - -def merge_candidates(sdp: str, candidates: Iterable[IceCandidateLike]) -> str: - """Add HAP-signaled candidates to the m-sections they belong to. - - A candidate goes to the section with its mid, else to its m-line index, - else to the only section. Candidates already present are not repeated, - and a section takes at most :data:`MAX_CANDIDATES`; once every section is - full the rest is not looked at. - """ - lines = _split_lines(_checked_size(sdp)) - starts = [i for i, line in enumerate(lines) if line.startswith("m=")] - bounds = [*starts, len(lines)] - media = [lines[start:end] for start, end in pairwise(bounds)] - mids: dict[str, int] = {} - counts = [0] * len(media) - present: set[str] = set() - for index, section in enumerate(media): - for line in section: - if line.startswith("a=mid:"): - mids.setdefault(line[6:], index) - elif line.startswith("a=candidate:"): - present.add(line) - counts[index] += 1 - room = sum(max(0, MAX_CANDIDATES - count) for count in counts) - added: list[list[str]] = [[] for _ in media] - for candidate in candidates: - if not room: - break - if not candidate.candidate: - continue - if "\r" in candidate.candidate or "\n" in candidate.candidate: - raise SdpError("Invalid ICE candidate") - line = "a=candidate:" + candidate_value(candidate.candidate) - if line in present: - continue - index = _candidate_section(mids, len(media), candidate) - if counts[index] >= MAX_CANDIDATES: - continue - present.add(line) - counts[index] += 1 - room -= 1 - added[index].append(line) - for section, lines_added in zip(media, added, strict=True): - end = ( - section.index("a=end-of-candidates") - if "a=end-of-candidates" in section - else len(section) - ) - section[end:end] = lines_added - out = lines[: bounds[0]] + [line for section in media for line in section] - return "\r\n".join(out) + "\r\n" - - -def _candidate_section( - mids: dict[str, int], count: int, candidate: IceCandidateLike -) -> int: - index: int | None - if candidate.sdp_mid is not None: - index = mids.get(candidate.sdp_mid) - if index is None or candidate.sdp_mline_index not in (None, index): - raise SdpError("ICE candidate does not match a media section") - return index - index = candidate.sdp_mline_index - if index is None and count == 1: - index = 0 - if index is None or not 0 <= index < count: - raise SdpError("ICE candidate does not match a media section") - return index - - -def _default_address(candidates: Sequence[str]) -> tuple[str, int]: - """Return the connection address of the first candidate (RFC 8839 4.2.1.2).""" - for candidate in candidates: - parts = candidate_value(candidate).split() - if len(parts) >= 6: - return parts[4], int(parts[5]) - return "0.0.0.0", 9 # noqa: S104 - SDP placeholder address, not a bind - - -def _connection_line(address: str) -> str: - family = "IP6" if ":" in address else "IP4" - return f"c=IN {family} {address}" - - -def _transport_lines(local: LocalParams, setup: str) -> list[str]: - lines = [f"a=candidate:{candidate_value(c)}" for c in local.candidates] - if local.candidates: - lines.append("a=end-of-candidates") - return [ - *lines, - f"a=ice-ufrag:{local.ice_ufrag}", - f"a=ice-pwd:{local.ice_pwd}", - f"a=fingerprint:sha-256 {local.fingerprint}", - f"a=setup:{setup}", - ] - - -def _ssrc_lines(local: LocalParams, ssrc: int, track: str) -> list[str]: - return [ - f"a=msid:{local.stream_id} {track}", - f"a=ssrc:{ssrc} cname:{local.cname}", - f"a=ssrc:{ssrc} msid:{local.stream_id} {track}", - ] - - -def rid_line(video: VideoParams) -> str: - """Return the RID restriction line for the video tier.""" - return ( - f"a=rid:{RID} send max-width={video.width};max-height={video.height};" - f"max-fps={video.fps};max-br={video.peak_bitrate_kbps * 1000}" - ) - - -def _video_section( - local: LocalParams, - video: VideoParams, - payload_type: int, - mid: str, - direction: str, - setup: str, -) -> list[str]: - address, port = _default_address(local.candidates) - pt = payload_type - return [ - f"m=video {port} UDP/TLS/RTP/SAVPF {pt}", - _connection_line(address), - *_transport_lines(local, setup), - f"a=mid:{mid}", - f"a={direction}", - "a=rtcp-mux", - "a=rtcp-rsize", - f"a=rtpmap:{pt} H265/{VIDEO_CLOCK_RATE}", - f"a=rtcp-fb:{pt} nack", - f"a=rtcp-fb:{pt} nack pli", - f"a=rtcp-fb:{pt} ccm fir", - rid_line(video), - *_ssrc_lines(local, local.video_ssrc, "video"), - ] - - -def _audio_section( - local: LocalParams, payload_type: int, mid: str, direction: str, setup: str -) -> list[str]: - address, port = _default_address(local.candidates) - pt = payload_type - lines = [ - f"m=audio {port} UDP/TLS/RTP/SAVPF {pt}", - _connection_line(address), - *_transport_lines(local, setup), - f"a=mid:{mid}", - f"a={direction}", - "a=rtcp-mux", - "a=rtcp-rsize", - f"a=rtpmap:{pt} opus/{AUDIO_CLOCK_RATE}/2", - f"a=fmtp:{pt} minptime=20;useinbandfec=1;stereo=0;sprop-stereo=0", - f"a=ptime:{OPUS_PTIME}", - ] - if local.audio_ssrc is not None and direction in ("sendonly", "sendrecv"): - lines += _ssrc_lines(local, local.audio_ssrc, "audio") - return lines - - -def _session_lines(local: LocalParams, mids: Sequence[str]) -> list[str]: - return [ - "v=0", - f"o=- {local.session_id} {local.session_version} IN IP4 127.0.0.1", - "s=-", - "t=0 0", - f"a=group:BUNDLE {' '.join(mids)}", - f"a=msid-semantic: WMS {local.stream_id}", - ] - - -def build_offer( - local: LocalParams, video: VideoParams, audio: AudioParams | None -) -> str: - """Build the accessory's offer.""" - mids = [VIDEO_MID] + ([AUDIO_MID] if audio else []) - lines = _session_lines(local, mids) - lines += _video_section( - local, video, video.payload_type, VIDEO_MID, "sendonly", "actpass" - ) - if audio: - direction = "sendrecv" if audio.talkback else "sendonly" - lines += _audio_section( - local, audio.payload_type, AUDIO_MID, direction, "actpass" - ) - return "\r\n".join(lines) + "\r\n" - - -_ANSWER_DIRECTION = { - # (offered direction, we may receive) -> answered direction - ("sendrecv", True): "sendrecv", - ("sendrecv", False): "sendonly", - ("recvonly", True): "sendonly", - ("recvonly", False): "sendonly", - ("sendonly", True): "recvonly", - ("sendonly", False): "inactive", - ("inactive", True): "inactive", - ("inactive", False): "inactive", -} - - -@dataclass(frozen=True) -class Answer: - """An answer to a controller's offer and the payload types it selected.""" - - sdp: str - video_payload_type: int | None - audio_payload_type: int | None - video_mid: str | None - audio_mid: str | None - audio_direction: str | None = None - - -def build_answer( - offer: SessionDescription, - local: LocalParams, - setup: str, - video: VideoParams, - audio: AudioParams | None, -) -> Answer: - """Answer a controller's re-offer with the established transport. - - The first H.265 video section and the first Opus audio section are - accepted, every other section is rejected with port 0. - """ - accepted: list[str] = [] - body: list[str] = [] - video_pt = audio_pt = None - video_mid = audio_mid = audio_direction = None - for index, media in enumerate(offer.media): - mid = media.mid if media.mid is not None else str(index) - pt = None - if media.kind == "video" and video_pt is None and media.port: - pt = media.payload_type_for("H265", VIDEO_CLOCK_RATE) - elif media.kind == "audio" and audio and audio_pt is None and media.port: - pt = media.payload_type_for("opus", AUDIO_CLOCK_RATE, 2) - if pt is None: - body += _rejected_section(media, mid) - continue - accepted.append(mid) - if media.kind == "video": - video_pt, video_mid = pt, mid - direction = _ANSWER_DIRECTION[(media.direction, False)] - body += _video_section(local, video, pt, mid, direction, setup) - else: - audio_pt, audio_mid = pt, mid - talkback = bool(audio and audio.talkback) - direction = audio_direction = _ANSWER_DIRECTION[(media.direction, talkback)] - body += _audio_section(local, pt, mid, direction, setup) - if video_pt is None: - raise SdpError("Offer has no H.265 video") - lines = _session_lines(local, accepted) + body - return Answer( - "\r\n".join(lines) + "\r\n", - video_pt, - audio_pt, - video_mid, - audio_mid, - audio_direction, - ) - - -def _rejected_section(media: MediaDescription, mid: str) -> list[str]: - formats = " ".join(media.formats) or "0" - return [ - f"m={media.kind} 0 {media.protocol} {formats}", - "c=IN IP4 0.0.0.0", - f"a=mid:{mid}", - "a=inactive", - ] diff --git a/pyhap/media/source.py b/pyhap/media/source.py index 28ff7e5e..a2d202f8 100644 --- a/pyhap/media/source.py +++ b/pyhap/media/source.py @@ -32,7 +32,6 @@ from . import rtp from .hevc import AccessUnit, HevcDepacketizer, payload_starts_irap -from .sdp import VIDEO_CLOCK_RATE logger = logging.getLogger(__name__) @@ -167,7 +166,7 @@ def _measure(self, packet: rtp.RtpPacket, now: float) -> None: previous, self._keyframe_timestamp = self._keyframe_timestamp, packet.timestamp if previous is None or previous == packet.timestamp: return - interval = ((packet.timestamp - previous) & 0xFFFFFFFF) / VIDEO_CLOCK_RATE + interval = ((packet.timestamp - previous) & 0xFFFFFFFF) / rtp.VIDEO_CLOCK_RATE if interval > MAX_GOP: return self.gop = ( diff --git a/pyhap/media/worker.py b/pyhap/media/worker.py index 4293c8cc..945d5f9d 100644 --- a/pyhap/media/worker.py +++ b/pyhap/media/worker.py @@ -43,7 +43,6 @@ import orjson from . import MediaError -from .sdp import AudioParams, VideoParams logger = logging.getLogger(__name__) @@ -389,7 +388,3 @@ def _log_levels() -> list[int]: logging.getLogger(name).getEffectiveLevel() for name in FORWARDED_LOGGERS.values() ] - - -def _fields(params: VideoParams | AudioParams) -> dict[str, Any]: - return dict(vars(params)) diff --git a/tests/webrtc_fakes.py b/tests/media_fakes.py similarity index 100% rename from tests/webrtc_fakes.py rename to tests/media_fakes.py diff --git a/tests/webrtc_helpers.py b/tests/media_helpers.py similarity index 74% rename from tests/webrtc_helpers.py rename to tests/media_helpers.py index 42869caa..7ddbb944 100644 --- a/tests/webrtc_helpers.py +++ b/tests/media_helpers.py @@ -2,39 +2,12 @@ from __future__ import annotations -from dataclasses import dataclass import hashlib -import json -from pathlib import Path import struct from typing import Any from pyhap.media.hevc import NAL_AP, fragment -DATA = Path(__file__).parent / "data" - - -@dataclass -class IceCandidate: - """Stands in for pyhap.hksv3.IceCandidate: the same fields.""" - - candidate: str - sdp_mid: str | None = None - sdp_mline_index: int | None = None - - -@dataclass -class SFrameKey: - """Stands in for pyhap.hksv3.SFrameKey: the same fields.""" - - key: bytes - kid: int - - -def load_vectors(name: str) -> Any: - """Load a JSON vector file from tests/data.""" - return json.loads((DATA / name).read_text()) - def seeded_bytes(seed: str, length: int) -> bytes: """Reproduce the vector generator's SHA-256 counter byte stream.""" @@ -46,11 +19,6 @@ def seeded_bytes(seed: str, length: int) -> bytes: return out[:length] -def plaintext(spec: dict[str, Any]) -> bytes: - """Regenerate a plaintext stored as seed and length.""" - return seeded_bytes(spec["seed"], spec["len"]) - - def blob(value: bytes) -> Any: """Describe bytes the way the generator stores them.""" if len(value) <= 64: diff --git a/tests/test_media_ffmpeg_provider.py b/tests/test_media_ffmpeg_provider.py index 7b4fe6c9..74fdc9b2 100644 --- a/tests/test_media_ffmpeg_provider.py +++ b/tests/test_media_ffmpeg_provider.py @@ -23,7 +23,7 @@ redact_urls, ) -from .webrtc_fakes import ( +from .media_fakes import ( AUDIO, LOW, MEDIUM, diff --git a/tests/test_media_hevc.py b/tests/test_media_hevc.py index d999e5f4..fb26ff63 100644 --- a/tests/test_media_hevc.py +++ b/tests/test_media_hevc.py @@ -19,7 +19,7 @@ split_length_prefixed, ) -from .webrtc_helpers import blob, scrypted_input +from .media_helpers import blob, scrypted_input def nal(kind: int, size: int, seed: str = "", layer: int = 0, tid: int = 1) -> bytes: diff --git a/tests/test_media_sdp.py b/tests/test_media_sdp.py deleted file mode 100644 index a4cd3205..00000000 --- a/tests/test_media_sdp.py +++ /dev/null @@ -1,472 +0,0 @@ -"""Tests for pyhap.media.sdp.""" - -from __future__ import annotations - -from collections.abc import Callable -import dataclasses -from dataclasses import dataclass -from functools import partial - -import pytest - -from pyhap.media import sdp -from pyhap.media.sdp import AudioParams, LocalParams, SdpError, VideoParams - -from .webrtc_helpers import IceCandidate - -FINGERPRINT = ":".join(["AB"] * 32) -LOCAL = LocalParams( - ice_ufrag="uf01", - ice_pwd="pw0123456789abcdefghij", - fingerprint=FINGERPRINT, - cname="cname01", - stream_id="stream01", - video_ssrc=11111, - audio_ssrc=22222, - candidates=( - "candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host", - "candidate:b2 1 udp 1694498815 203.0.113.7 61000 typ srflx raddr " - "192.168.1.20 rport 50000", - ), - session_id=42, -) -VIDEO = VideoParams(width=1920, height=1080, fps=30, peak_bitrate_kbps=2000) - -GOLDEN_OFFER = """v=0 -o=- 42 2 IN IP4 127.0.0.1 -s=- -t=0 0 -a=group:BUNDLE 0 1 -a=msid-semantic: WMS stream01 -m=video 50000 UDP/TLS/RTP/SAVPF 99 -c=IN IP4 192.168.1.20 -a=candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host -a=candidate:{srflx} -a=end-of-candidates -a=ice-ufrag:uf01 -a=ice-pwd:pw0123456789abcdefghij -a=fingerprint:sha-256 {fp} -a=setup:actpass -a=mid:0 -a=sendonly -a=rtcp-mux -a=rtcp-rsize -a=rtpmap:99 H265/90000 -a=rtcp-fb:99 nack -a=rtcp-fb:99 nack pli -a=rtcp-fb:99 ccm fir -a=rid:1 send max-width=1920;max-height=1080;max-fps=30;max-br=2000000 -a=msid:stream01 video -a=ssrc:11111 cname:cname01 -a=ssrc:11111 msid:stream01 video -m=audio 50000 UDP/TLS/RTP/SAVPF 110 -c=IN IP4 192.168.1.20 -a=candidate:a1 1 udp 2130706431 192.168.1.20 50000 typ host -a=candidate:{srflx} -a=end-of-candidates -a=ice-ufrag:uf01 -a=ice-pwd:pw0123456789abcdefghij -a=fingerprint:sha-256 {fp} -a=setup:actpass -a=mid:1 -a=sendonly -a=rtcp-mux -a=rtcp-rsize -a=rtpmap:110 opus/48000/2 -a=fmtp:110 minptime=20;useinbandfec=1;stereo=0;sprop-stereo=0 -a=ptime:20 -a=msid:stream01 audio -a=ssrc:22222 cname:cname01 -a=ssrc:22222 msid:stream01 audio -""" -GOLDEN_OFFER = ( - GOLDEN_OFFER.replace("{fp}", FINGERPRINT) - .replace("{srflx}", LOCAL.candidates[1].removeprefix("candidate:")) - .replace("\n", "\r\n") -) - -ANSWER = """v=0 -o=- 7 2 IN IP4 127.0.0.1 -s=- -t=0 0 -a=group:BUNDLE 0 1 -a=ice-ufrag:remU -a=ice-pwd:remotepassword0123456789 -a=fingerprint:sha-256 0a:0b:0c -m=video 9 UDP/TLS/RTP/SAVPF 100 -c=IN IP4 0.0.0.0 -a=setup:active -a=mid:0 -a=recvonly -a=rtcp-mux -a=rtpmap:100 H265/90000 -a=rtcp-fb:100 ccm fir -a=candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host -m=audio 9 UDP/TLS/RTP/SAVPF 111 -c=IN IP4 0.0.0.0 -a=setup:active -a=mid:1 -a=sendrecv -a=rtpmap:111 opus/48000/2 -a=ssrc:3333 cname:remote -a=ssrc:3333 msid:x y -a=end-of-candidates -""" - -REOFFER = """v=0 -o=- 9 3 IN IP4 127.0.0.1 -s=- -t=0 0 -a=group:BUNDLE v a d -m=video 9 UDP/TLS/RTP/SAVPF 96 98 -c=IN IP4 0.0.0.0 -a=ice-ufrag:remU -a=ice-pwd:remotepassword0123456789 -a=fingerprint:sha-256 0A:0B:0C -a=setup:actpass -a=mid:v -a=recvonly -a=rtpmap:96 H264/90000 -a=rtpmap:98 H265/90000 -m=audio 9 UDP/TLS/RTP/SAVPF 0 109 -c=IN IP4 0.0.0.0 -a=mid:a -a=sendrecv -a=rtpmap:0 PCMU/8000 -a=rtpmap:109 opus/48000/2 -m=application 9 UDP/DTLS/SCTP webrtc-datachannel -c=IN IP4 0.0.0.0 -a=mid:d -""" - - -def offer_with(**changes: object) -> str: - """Build an offer with modified parameters.""" - local = dataclasses.replace(LOCAL, **changes) # type: ignore[arg-type] - return sdp.build_offer(local, VIDEO, AudioParams()) - - -def directions(description: sdp.SessionDescription) -> list[str]: - """Return each section's direction.""" - return [media.direction for media in description.media] - - -def ports(description: sdp.SessionDescription) -> list[int]: - """Return each section's port.""" - return [media.port for media in description.media] - - -def test_golden_offer() -> None: - """Verify the full offer text.""" - assert sdp.build_offer(LOCAL, VIDEO, AudioParams()) == GOLDEN_OFFER - - -def test_offer_without_audio() -> None: - """Verify a video-only offer bundles one section.""" - parsed = sdp.parse(sdp.build_offer(LOCAL, VIDEO, None)) - assert parsed.bundle == ["0"] - assert ports(parsed) == [50000] - - -def test_offer_with_talkback() -> None: - """Verify talkback makes the audio section sendrecv.""" - offer = sdp.build_offer(LOCAL, VIDEO, AudioParams(talkback=True)) - assert directions(sdp.parse(offer)) == ["sendonly", "sendrecv"] - - -def test_offer_custom_payload_type() -> None: - """Verify the payload types are configurable.""" - video = VideoParams(1280, 720, 15, 800, payload_type=100) - offer = sdp.parse(sdp.build_offer(LOCAL, video, AudioParams(payload_type=111))) - assert offer.media[0].rtpmap == {100: ("H265", 90000, None)} - assert offer.media[1].rtpmap == {111: ("opus", 48000, 2)} - assert offer.media[0].rids == [ - "1 send max-width=1280;max-height=720;max-fps=15;max-br=800000" - ] - - -def test_offer_without_candidates_uses_placeholder() -> None: - """Verify the discard address is used when no candidate is known.""" - assert "m=video 9 UDP/TLS/RTP/SAVPF 99\r\nc=IN IP4 0.0.0.0\r\n" in offer_with( - candidates=() - ) - assert "a=end-of-candidates" not in offer_with(candidates=()) - - -def test_offer_ipv6_default_candidate() -> None: - """Verify an IPv6 default candidate gives an IP6 connection line.""" - offer = offer_with(candidates=("candidate:c 1 udp 1 fd00::5 4000 typ host",)) - assert "m=video 4000 UDP/TLS/RTP/SAVPF 99\r\nc=IN IP6 fd00::5\r\n" in offer - - -def test_parse_answer() -> None: - """Verify session-level attributes apply to every section.""" - answer = sdp.parse(ANSWER) - transport = answer.transport() - assert (transport.mid, transport.ice_ufrag, transport.ice_pwd) == ( - "0", - "remU", - "remotepassword0123456789", - ) - assert transport.fingerprints == [("sha-256", "0A:0B:0C")] - assert transport.setup == "active" - assert answer.candidates == ["1 1 udp 2122260223 10.0.0.2 52000 typ host"] - assert directions(answer) == ["recvonly", "sendrecv"] - assert list(answer.media[1].ssrcs) == [3333] - assert answer.media[1].payload_type_for("opus", 48000, 2) == 111 - - -def test_parse_media_level_overrides_session() -> None: - """Verify media-level ICE attributes win over session-level ones.""" - text = ANSWER.replace( - "a=setup:active\na=mid:0", "a=setup:active\na=ice-ufrag:own\na=mid:0" - ) - assert sdp.parse(text).media[0].ice_ufrag == "own" - assert sdp.parse(text).media[1].ice_ufrag == "remU" - - -def test_parse_ice_lite() -> None: - """Verify a=ice-lite is detected.""" - assert sdp.parse("a=ice-lite\r\n" + ANSWER).ice_lite - - -def test_transport_skips_rejected_section() -> None: - """Verify an unbundled answer uses the first accepted section.""" - text = ANSWER.replace("a=group:BUNDLE 0 1\n", "").replace("m=video 9", "m=video 0") - assert sdp.parse(text).transport().mid == "1" - - -def test_transport_requires_media() -> None: - """Verify an SDP without usable media is rejected.""" - with pytest.raises(SdpError, match="no usable"): - sdp.parse("v=0\r\n").transport() - - -@pytest.mark.parametrize( - "text", - [ - "m=video\r\n", - "m=video x UDP 96\r\n", - "m=video 9 UDP 96\r\na=rtpmap:x\r\n", - ], - ids=["short", "port", "rtpmap"], -) -def test_parse_rejects_malformed(text: str) -> None: - """Verify malformed lines raise SdpError.""" - with pytest.raises(SdpError): - sdp.parse(text) - - -@pytest.mark.parametrize( - ("candidate", "section"), - [ - (IceCandidate("candidate:9 1 udp 1 10.0.0.9 9 typ host", "1", 1), 1), - (IceCandidate("candidate:9 1 udp 1 10.0.0.9 9 typ host", "0"), 0), - (IceCandidate("a=candidate:9 1 udp 1 10.0.0.9 9 typ host", None, 1), 1), - ], - ids=["mid-and-index", "mid", "index"], -) -def test_merge_candidates(candidate: IceCandidate, section: int) -> None: - """Verify HAP candidates land in their section before end-of-candidates.""" - merged = sdp.parse(sdp.merge_candidates(ANSWER, [candidate])) - assert merged.media[section].candidates[-1] == "9 1 udp 1 10.0.0.9 9 typ host" - assert sdp.merge_candidates(ANSWER, [candidate]).endswith( - "a=candidate:9 1 udp 1 10.0.0.9 9 typ host\r\na=end-of-candidates\r\n" - ) is (section == 1) - - -def test_merge_candidates_single_section_without_mid() -> None: - """Verify a candidate without mid or index goes to the only section.""" - single = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 100\r\na=mid:0\r\n" - merged = sdp.merge_candidates( - single, [IceCandidate("candidate:1 1 udp 1 h 1 typ host")] - ) - assert sdp.parse(merged).candidates == ["1 1 udp 1 h 1 typ host"] - - -def test_merge_candidates_deduplicates() -> None: - """Verify a candidate already in the SDP is not added twice.""" - existing = IceCandidate("candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host", "0") - assert sdp.merge_candidates(ANSWER, [existing, IceCandidate("")]) == ANSWER.replace( - "\n", "\r\n" - ) - - -@pytest.mark.parametrize( - "candidate", - [ - IceCandidate("candidate:1 1 udp 1 h 1 typ host", "9"), - IceCandidate("candidate:1 1 udp 1 h 1 typ host", "0", 1), - IceCandidate("candidate:1 1 udp 1 h 1 typ host", None, 5), - IceCandidate("candidate:1 1 udp 1 h 1 typ host"), - IceCandidate("candidate:1 1 udp 1 h 1 typ host\r\na=x", "0"), - ], - ids=["unknown-mid", "mid-index-mismatch", "index-range", "ambiguous", "newline"], -) -def test_merge_candidates_rejects(candidate: IceCandidate) -> None: - """Verify candidates that match no section are rejected.""" - with pytest.raises(SdpError): - sdp.merge_candidates(ANSWER, [candidate]) - - -def test_merge_candidates_caps_each_section() -> None: - """Verify a section takes at most MAX_CANDIDATES candidates.""" - merged = sdp.parse(sdp.merge_candidates(ANSWER, many_candidates(40))) - assert len(merged.media[0].candidates) == sdp.MAX_CANDIDATES - assert merged.media[0].candidates[0] == "1 1 udp 2122260223 10.0.0.2 52000 typ host" - - -def test_merge_candidates_skips_present_before_matching() -> None: - """Verify a known candidate is skipped before its section is looked up.""" - known = IceCandidate("candidate:1 1 udp 2122260223 10.0.0.2 52000 typ host", "9") - assert sdp.merge_candidates(ANSWER, [known]) == ANSWER.replace("\n", "\r\n") - - -def test_merge_candidates_stops_when_full() -> None: - """Verify candidates after the last free slot are not looked at.""" - unmatched = IceCandidate("candidate:x 1 udp 1 10.3.0.1 9 typ host", "9") - one_section = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 100\r\na=mid:0\r\n" - full = [*many_candidates(sdp.MAX_CANDIDATES), unmatched] - merged = sdp.parse(sdp.merge_candidates(one_section, full)) - assert len(merged.candidates) == sdp.MAX_CANDIDATES - - -@pytest.mark.parametrize( - "function", - [sdp.parse, partial(sdp.merge_candidates, candidates=[])], - ids=["parse", "merge"], -) -def test_rejects_oversized_sdp(function: Callable[[str], object]) -> None: - """Verify an SDP over MAX_SDP_SIZE is rejected before it is split.""" - with pytest.raises(SdpError, match="larger than"): - function(ANSWER + "a=x\n" * sdp.MAX_SDP_SIZE) - - -def test_parse_accepts_max_media_sections() -> None: - """Verify an SDP with MAX_MEDIA_SECTIONS media sections is parsed.""" - text = "v=0\r\n" + "m=audio 0 UDP 0\r\n" * sdp.MAX_MEDIA_SECTIONS - assert len(sdp.parse(text).media) == sdp.MAX_MEDIA_SECTIONS - - -def test_parse_rejects_too_many_media_sections() -> None: - """Verify an SDP with more than MAX_MEDIA_SECTIONS sections is rejected.""" - text = "v=0\r\n" + "m=audio 0 UDP 0\r\n" * (sdp.MAX_MEDIA_SECTIONS + 1) - with pytest.raises(SdpError, match="Too many media sections"): - sdp.parse(text) - - -def test_error_shows_a_short_line() -> None: - """Verify an error message shows at most 80 characters of the line.""" - with pytest.raises(SdpError) as err: - sdp.parse("m=video 9 UDP 96\r\na=rtpmap:" + "x" * 500 + "\r\n") - assert str(err.value) == f"Invalid attribute: {('a=rtpmap:' + 'x' * 71)!r}" - - -def test_parse_caps_candidates() -> None: - """Verify parsing keeps at most MAX_CANDIDATES per section.""" - text = ANSWER + "".join(candidate_lines(40)) - assert len(sdp.parse(text).media[1].candidates) == sdp.MAX_CANDIDATES - assert len(sdp.parse(text).candidates) == sdp.MAX_CANDIDATES - - -def test_video_params_pacing_bitrate() -> None: - """Verify pacing uses the target bit rate and falls back to the peak.""" - assert VIDEO.pacing_bitrate_kbps == 2000 - assert ( - dataclasses.replace(VIDEO, target_bitrate_kbps=900).pacing_bitrate_kbps == 900 - ) - - -def test_answer_to_reoffer() -> None: - """Verify a re-offer is answered with its payload types and mids.""" - answer = sdp.build_answer( - sdp.parse(REOFFER), LOCAL, "passive", VIDEO, AudioParams() - ) - parsed = sdp.parse(answer.sdp) - assert (answer.video_payload_type, answer.audio_payload_type) == (98, 109) - assert (answer.video_mid, answer.audio_mid) == ("v", "a") - assert answer.audio_direction == "sendonly" - assert parsed.bundle == ["v", "a"] - assert ports(parsed) == [50000, 50000, 0] - assert directions(parsed) == ["sendonly", "sendonly", "inactive"] - assert parsed.media[0].formats == ["98"] - assert parsed.media[0].setup == "passive" - assert parsed.media[0].rids == [sdp.rid_line(VIDEO)[len("a=rid:") :]] - - -def test_answer_to_reoffer_with_talkback() -> None: - """Verify talkback accepts the controller's audio.""" - answer = sdp.build_answer( - sdp.parse(REOFFER), LOCAL, "active", VIDEO, AudioParams(talkback=True) - ) - assert directions(sdp.parse(answer.sdp))[1] == "sendrecv" - assert answer.audio_direction == "sendrecv" - - -def test_answer_to_reoffer_without_audio() -> None: - """Verify audio is rejected when the sender has none.""" - answer = sdp.build_answer(sdp.parse(REOFFER), LOCAL, "active", VIDEO, None) - assert answer.audio_payload_type is None - assert answer.audio_direction is None - assert ports(sdp.parse(answer.sdp)) == [50000, 0, 0] - - -def test_answer_requires_h265() -> None: - """Verify a re-offer without H.265 cannot be answered.""" - with pytest.raises(SdpError, match="H.265"): - sdp.build_answer( - sdp.parse(REOFFER.replace("H265", "VP8")), LOCAL, "active", VIDEO, None - ) - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ("a=candidate:1 1 udp 1 h 1 typ host", "1 1 udp 1 h 1 typ host"), - ("candidate:1 1 udp 1 h 1 typ host", "1 1 udp 1 h 1 typ host"), - (" 1 1 udp 1 h 1 typ host ", "1 1 udp 1 h 1 typ host"), - ], -) -def test_candidate_value(value: str, expected: str) -> None: - """Verify candidate prefixes are stripped.""" - assert sdp.candidate_value(value) == expected - - -def many_candidates(count: int) -> list[IceCandidate]: - """Return ``count`` distinct HAP candidates for mid 0.""" - return [ - IceCandidate(f"candidate:{i} 1 udp 1 10.1.0.{i} 9 typ host", "0") - for i in range(count) - ] - - -def candidate_lines(count: int) -> list[str]: - """Return ``count`` distinct a=candidate lines.""" - return [f"a=candidate:{i} 1 udp 1 10.2.0.{i} 9 typ host\n" for i in range(count)] - - -def test_payload_types_ignore_non_ascii_digits() -> None: - """Verify formats like a superscript two are skipped, not a crash.""" - parsed = sdp.parse("v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 \u00b2\r\n") - assert parsed.media[0].payload_types == [96] - - -@dataclass(frozen=True) -class Tier: - """Stands in for pyhap.hksv3.VideoStreamTier: the fields used.""" - - width: int - height: int - frame_rate: int - target_bitrate: int - peak_bitrate: int | None = None - - -@pytest.mark.parametrize( - ("peak", "max_br"), [(3000, 3000), (None, 1200)], ids=["peak", "no-peak"] -) -def test_video_params_from_tier(peak: int | None, max_br: int) -> None: - """Verify a HAP tier maps to the offer, max-br from the peak bit rate.""" - video = VideoParams.from_tier(Tier(1280, 720, 24, 1200, peak)) - assert video == VideoParams(1280, 720, 24, max_br, 99, 1200) - assert sdp.rid_line(video).endswith(f"max-fps=24;max-br={max_br * 1000}") From 1b10be97f91cad85558ef6b82bf4a4aa5efdd6b9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:46 +0100 Subject: [PATCH 46/56] Keep read back answers private, also when the setter sets them A subclass that still sets the Setup Endpoints answer as the value made it readable for every controller. Whatever the setter of a read back characteristic leaves in the value now goes to the writer's store and the former value comes back. A plain write to another write response characteristic stays readable for everyone, as in #535. The read back test now checks the answer's status instead of the session ID. --- pyhap/accessory_driver.py | 20 +++++++++++----- tests/test_accessory_driver.py | 39 +++++++++++++++++++++++++----- tests/test_camera.py | 44 +++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 949836e8..8206e13e 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1270,10 +1270,7 @@ def _run_characteristic_writes( if value is not None: self._write_responses.get(client_addr, {}).pop(char, None) private_write = ( - ( - HAP_PERMISSION_WRITE_RESPONSE in char.properties[PROP_PERMISSIONS] - or char.read_back_response - ) + (write_response or char.read_back_response) and value is not None and HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] ) @@ -1291,8 +1288,19 @@ def _run_characteristic_writes( # the writer reads the answer back, see _store_write_response. # client_update_value stored the request as the value, so put # back the former one unless the setter set a value of its own. - if private_write and char.value == _stored_value(char, value): - char.value = previous_value + # Whatever the setter of a read back characteristic leaves in the + # value is an answer for the writer only, e.g. from a subclass that + # still sets the Setup Endpoints answer itself. + if private_write: + own_value = char.value + if char.read_back_response: + if set_result_value is None and own_value != _stored_value( + char, value + ): + set_result_value = own_value + char.value = previous_value + elif own_value == _stored_value(char, value): + char.value = previous_value result: dict[str, Any] = {HAP_REPR_STATUS: set_result} if inspect.iscoroutine(set_result_value): diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index c3ce2c6f..1aa0d2f2 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1333,14 +1333,14 @@ async def test_write_response_dropped_on_disconnect(driver: AccessoryDriver) -> def test_plain_write_replaces_write_response(driver: AccessoryDriver) -> None: - """A write without ``r`` drops the writer's former response, privately too.""" + """A write without ``r`` drops the writer's former response.""" acc, char, char_id = _add_write_response_char(driver, False) _write(driver, acc, char, **{HAP_REPR_VALUE: 5, HAP_REPR_WRITE_RESPONSE: True}) _write(driver, acc, char, **{HAP_REPR_VALUE: 9}) - assert _read(driver, char_id, WRITER) == 0 - assert _read(driver, char_id, OTHER_CONTROLLER) == 0 + assert _read(driver, char_id, WRITER) == 9 + assert _read(driver, char_id, OTHER_CONTROLLER) == 9 @pytest.mark.parametrize( @@ -1479,15 +1479,15 @@ def test_write_response_without_client_not_kept(driver: AccessoryDriver) -> None pytest.param({HAP_REPR_WRITE_RESPONSE: False}, id="declined"), ], ) -def test_written_value_private_without_write_response( +def test_written_value_readable_without_write_response( driver: AccessoryDriver, reader: Any, query: dict[str, Any] ) -> None: - """Without a write response the written value is not left readable either.""" + """Without a write response the written value is read by every controller.""" acc, char, char_id = _add_write_response_char(driver, False) _write(driver, acc, char, **{HAP_REPR_VALUE: 5, **query}) - assert _read(driver, char_id, reader) == 0 + assert _read(driver, char_id, reader) == 5 def test_service_callbacks(driver: AccessoryDriver) -> None: @@ -2631,6 +2631,33 @@ def test_read_back_response_only_for_writer( assert acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 0 +@pytest.mark.parametrize( + ("reader", "expected"), + [ + pytest.param(WRITER, 42, id="writer"), + pytest.param(OTHER_CONTROLLER, 0, id="other-controller"), + ], +) +def test_read_back_value_set_by_setter_only_for_writer( + driver: AccessoryDriver, reader: Any, expected: int +) -> None: + """A value the setter sets itself on a read back char stays with the writer.""" + acc, char = _add_read_back_char(driver) + + def _setter(value: int) -> None: + char.set_value(42) + + char.setter_callback = _setter + + assert _write(driver, acc, char, **{HAP_REPR_VALUE: 5}) is None + + assert _read(driver, f"{acc.aid}.{acc.iid_manager.get_iid(char)}", reader) == ( + expected + ) + acc_rep = driver.get_accessories()[HAP_REPR_ACCS][-1] + assert acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 0 + + async def test_async_read_back_response(driver: AccessoryDriver) -> None: """A coroutine setter's answer is read back, not sent as a write response.""" acc, char = _add_read_back_char(driver) diff --git a/tests/test_camera.py b/tests/test_camera.py index d70e7fd0..3af026ce 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -449,6 +449,48 @@ def test_setup_endpoints_read_back_by_writer_only(driver: Any) -> None: (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ "characteristics" ] - assert writer["value"][:20] == "ARCszGzBBWNFFY2pdLRQ" + # The writer reads the answer, which carries a status, not its request. + answer = tlv.decode(writer["value"], from_base64=True) + assert answer[b"\x01"] == tlv.decode(request, from_base64=True)[b"\x01"] + assert answer[b"\x02"] == b"\x00" + assert other["value"] == "" + assert char.value == "" + + +def test_setup_endpoints_set_by_subclass_read_back_by_writer_only( + driver: Any, +) -> None: + """Test an answer a subclass sets as the value stays with the writer.""" + + class _Camera(camera.Camera): + def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: + answer = super().set_endpoints(value, stream_idx) + management = self.get_service("CameraRTPStreamManagement") + assert management is not None + management.get_characteristic("SetupEndpoints").set_value(answer) + + acc = _Camera(_OPTIONS, driver, "Camera") + driver.add_accessory(acc) + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + char = management.get_characteristic("SetupEndpoints") + iid = acc.iid_manager.get_iid(char) + request = ( + "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" + "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" + "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" + ) + query = {"characteristics": [{"aid": acc.aid, "iid": iid, "value": request}]} + + assert driver.set_characteristics(query, ("192.168.1.114", 50000)) is None + + char_id = f"{acc.aid}.{iid}" + (writer,) = driver.get_characteristics([char_id], ("192.168.1.114", 50000))[ + "characteristics" + ] + (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ + "characteristics" + ] + assert tlv.decode(writer["value"], from_base64=True)[b"\x02"] == b"\x00" assert other["value"] == "" assert char.value == "" From c124c123e145616f25f49057f18eacf2462337e1 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:39:30 +0100 Subject: [PATCH 47/56] Harden the media core from the review - A failed first ffmpeg start reaches on_error too. - The worker drops log records while the parent reads slowly and gives up on a parent that stopped reading, instead of buffering without bound. - A BYE without sources is parsed (RFC 3550 6.6) and does not end the compound packet. - Inserted parameter sets go after an access unit delimiter. - Closing the pool waits for a lease being opened. - pylibsrtp gets a lower bound, talkback_available() is deprecated. - Tests for the pool, a crashed worker and the bounded worker output. --- pyhap/camera_talkback.py | 6 +- pyhap/media/_worker.py | 20 +++- pyhap/media/ffmpeg.py | 11 ++- pyhap/media/hevc.py | 5 +- pyhap/media/pool.py | 9 +- pyhap/media/rtp.py | 17 +++- pyproject.toml | 2 +- tests/test_media_ffmpeg_provider.py | 18 ++++ tests/test_media_hevc.py | 14 +++ tests/test_media_pool.py | 147 ++++++++++++++++++++++++++++ tests/test_media_rtp.py | 24 +++-- tests/test_media_worker.py | 59 ++++++++++- 12 files changed, 307 insertions(+), 25 deletions(-) create mode 100644 tests/test_media_pool.py diff --git a/pyhap/camera_talkback.py b/pyhap/camera_talkback.py index 04305356..9ba3e4f7 100644 --- a/pyhap/camera_talkback.py +++ b/pyhap/camera_talkback.py @@ -39,7 +39,11 @@ def talkback_available() -> bool: - """Whether talkback is supported, always since pylibsrtp is a dependency.""" + """Whether talkback is supported, always since pylibsrtp is a dependency. + + Deprecated: kept for callers of the optional dependency check, it will be + removed in a future release. + """ return True diff --git a/pyhap/media/_worker.py b/pyhap/media/_worker.py index ccca28d8..f5e2b447 100644 --- a/pyhap/media/_worker.py +++ b/pyhap/media/_worker.py @@ -21,6 +21,11 @@ logger = logging.getLogger(__name__) +# Bytes waiting for the parent above which log records are dropped, and +# above which the worker gives up on a parent that stopped reading. +LOG_DROP_BUFFER = 1024 * 1024 +MAX_OUTPUT_BUFFER = 64 * 1024 * 1024 + class _LogForwarder(logging.Handler): def __init__(self, server: _Server) -> None: @@ -52,8 +57,19 @@ def __init__(self, output: asyncio.WriteTransport) -> None: self.stopping = asyncio.Event() def send(self, message: dict[str, Any], blob: bytes = b"") -> None: - if not self._output.is_closing(): - self._output.write(encode_message(message, blob)) + output = self._output + if output.is_closing(): + return + buffered = output.get_write_buffer_size() + if message.get("event") == "log" and buffered > LOG_DROP_BUFFER: + # A parent that reads slowly loses log records, not media events. + return + if buffered > MAX_OUTPUT_BUFFER: + # The parent stopped reading; it sees the worker as gone. + output.close() + self.stopping.set() + return + output.write(encode_message(message, blob)) def handle(self, message: dict[str, Any]) -> None: task = asyncio.get_running_loop().create_task(self._respond(message)) diff --git a/pyhap/media/ffmpeg.py b/pyhap/media/ffmpeg.py index f66fd136..3df722b6 100644 --- a/pyhap/media/ffmpeg.py +++ b/pyhap/media/ffmpeg.py @@ -348,8 +348,12 @@ async def start(self, rtp_input: tuple[str, int, int]) -> None: self._running = True try: await self._spawn() - except BaseException: + except BaseException as err: self._running = False + if isinstance(err, OSError): + message = f"could not start: {err}" + logger.warning("ffmpeg for %s %s", self.name, message) + self._report(message) raise self._watcher = asyncio.ensure_future(self._watch()) @@ -404,10 +408,13 @@ async def _back_off(self, message: str) -> None: await asyncio.wait_for(self._restart_now.wait(), delay) def _error(self, message: str, delay: float) -> None: - self.last_error = message logger.warning( "ffmpeg for %s %s, restarting in %.1f s", self.name, message, delay ) + self._report(message) + + def _report(self, message: str) -> None: + self.last_error = message if self.on_error is not None: try: self.on_error(self.name, message) diff --git a/pyhap/media/hevc.py b/pyhap/media/hevc.py index 2881b007..cacd1fa1 100644 --- a/pyhap/media/hevc.py +++ b/pyhap/media/hevc.py @@ -33,6 +33,7 @@ NAL_VPS = 32 NAL_SPS = 33 NAL_PPS = 34 +NAL_AUD = 35 NAL_AP = 48 NAL_FU = 49 IRAP_FIRST = 16 @@ -256,7 +257,9 @@ def _complete(self, nals: list[bytes]) -> list[bytes]: for kind in PARAMETER_SETS if kind not in present and kind in self.parameter_sets ] - return missing + nals + # An access unit delimiter stays the first NAL unit (H.265 7.4.2.4.4). + lead = 1 if nals and nal_type(nals[0]) == NAL_AUD else 0 + return nals[:lead] + missing + nals[lead:] def _drop(self) -> None: if self._nals or self._fragment is not None or self._damaged: diff --git a/pyhap/media/pool.py b/pyhap/media/pool.py index 79871ca0..74d3d297 100644 --- a/pyhap/media/pool.py +++ b/pyhap/media/pool.py @@ -305,10 +305,13 @@ def _log(self, channel: _Channel) -> None: async def close(self) -> None: """Stop every pull and close every source, e.g. on shutdown.""" - channels = list(self._channels.values()) - self._channels.clear() + # A lease being opened finishes first, so its channel is closed too. + async with self._lock: + channels = list(self._channels.values()) + self._channels.clear() + for channel in channels: + channel.closed = True for channel in channels: - channel.closed = True await self._sync_pull(channel) await self._close_source(channel, "pool closed") while self._tasks: diff --git a/pyhap/media/rtp.py b/pyhap/media/rtp.py index badd4396..f4cdca30 100644 --- a/pyhap/media/rtp.py +++ b/pyhap/media/rtp.py @@ -124,17 +124,24 @@ def parse_rtcp(data: bytes) -> list[RtcpMessage]: """Parse a compound RTCP packet; stops at the first malformed part. Every part must be version 2, fit in ``data`` and carry at least the - sender SSRC (RFC 3550 section 6.4). + sender SSRC (RFC 3550 section 6.4), except a BYE without sources, whose + ``sender_ssrc`` is 0. """ messages = [] offset = 0 - while offset + 8 <= len(data): + while offset + 4 <= len(data): first, packet_type, length = struct.unpack_from(">BBH", data, offset) end = offset + 4 * (length + 1) - if first >> 6 != RTP_VERSION or length == 0 or end > len(data): + if first >> 6 != RTP_VERSION or end > len(data): break - body = data[offset + 4 : end] - messages.append(_parse_one(first & 0x1F, packet_type, body)) + if length == 0: + # Only a BYE without sources is just a header (RFC 3550 6.6). + if packet_type != RTCP_BYE or first & 0x1F: + break + messages.append(RtcpMessage(packet_type, 0, 0)) + else: + body = data[offset + 4 : end] + messages.append(_parse_one(first & 0x1F, packet_type, body)) offset = end return messages diff --git a/pyproject.toml b/pyproject.toml index 3df329f2..17ebd85e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "orjson>=3.7.2", "zeroconf>=0.36.2", "h11", - "pylibsrtp", + "pylibsrtp>=0.10", ] dynamic = ["version"] diff --git a/tests/test_media_ffmpeg_provider.py b/tests/test_media_ffmpeg_provider.py index 74fdc9b2..43158560 100644 --- a/tests/test_media_ffmpeg_provider.py +++ b/tests/test_media_ffmpeg_provider.py @@ -361,6 +361,24 @@ async def test_ffmpeg_pull_backs_off_failed_starts( assert pull.restarts == 0 +async def test_failed_first_start_reaches_on_error(tmp_path: Path) -> None: + """Verify a missing ffmpeg raises and is reported to on_error.""" + errors: list[tuple[str, str]] = [] + pull = FFmpegPull( + lambda _rtp_input: [str(tmp_path / "missing-ffmpeg")], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda name, message: errors.append((name, message)), + ) + with pytest.raises(OSError): + await pull.start(("127.0.0.1", 1, 2)) + assert errors and errors[0][0] == "medium" + assert errors[0][1].startswith("could not start") + assert pull.last_error == errors[0][1] + assert pull.pid is None + + @pytest.mark.parametrize( ("gop", "age", "measured"), [ diff --git a/tests/test_media_hevc.py b/tests/test_media_hevc.py index fb26ff63..43b78e84 100644 --- a/tests/test_media_hevc.py +++ b/tests/test_media_hevc.py @@ -154,6 +154,20 @@ def test_parameter_sets_inserted_before_irap() -> None: assert keyframes_of(units) == [True, False, True] +def test_parameter_sets_go_after_an_access_unit_delimiter() -> None: + """Verify an AUD stays the first NAL unit of an IRAP access unit.""" + packetizer = HevcPacketizer() + aud = nal(35, 3) + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([aud, IDR]), + ], + ) + assert units[-1].nals == [aud, VPS, SPS, PPS, IDR] + + def test_updated_parameter_sets_are_used() -> None: """Verify the latest parameter sets are the ones inserted.""" packetizer = HevcPacketizer() diff --git a/tests/test_media_pool.py b/tests/test_media_pool.py new file mode 100644 index 00000000..32af1a71 --- /dev/null +++ b/tests/test_media_pool.py @@ -0,0 +1,147 @@ +"""Tests for pyhap.media.pool: sharing one source and pull per channel.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from pyhap.media import MediaError +from pyhap.media.pool import SourcePool + +pytestmark = pytest.mark.timeout(5) + + +class Source: + """A media source that records its close.""" + + def __init__(self, port: int) -> None: + """Bind nothing, take ``port`` as the video port.""" + self.on_closed: Callable[[str], None] | None = None + self.closed = False + self.rtp_input = ("127.0.0.1", port, port + 1) + self.has_audio = True + self.close_reasons: list[str] = [] + + async def close(self, reason: str = "closed") -> None: + """Close once.""" + self.closed = True + self.close_reasons.append(reason) + + +class Pull: + """A channel pull that records its starts and stops.""" + + def __init__(self) -> None: + """Not running.""" + self.running = False + self.starts = 0 + self.stops = 0 + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Start pulling.""" + self.running = True + self.starts += 1 + + async def stop(self) -> None: + """Stop pulling.""" + self.running = False + self.stops += 1 + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Ignore keyframe requests.""" + + +@pytest.fixture +def sources(monkeypatch: pytest.MonkeyPatch) -> list[Source]: + """The sources the pool opens, in process and without ports.""" + opened: list[Source] = [] + + async def _open_source(self: SourcePool, audio: bool) -> Source: + await asyncio.sleep(0) + source = Source(5000 + 2 * len(opened)) + opened.append(source) + return source + + monkeypatch.setattr(SourcePool, "_open_source", _open_source) + return opened + + +async def test_leases_share_one_source_and_pull(sources: list[Source]) -> None: + """Verify the pull runs while a lease is active and the last one closes.""" + pool = SourcePool() + pull = Pull() + first = await pool.lease("high", lambda: pull) + second = await pool.lease("high", Pull) + assert len(sources) == 1 + assert first.source is second.source + + await first.activate() + await second.activate() + assert pull.starts == 1 + await first.release() + assert pull.running + await second.release() + + assert not pull.running + assert sources[0].close_reasons == ["no sessions left"] + assert pool.counts() == {} + + +async def test_cancelled_lease_is_given_back(sources: list[Source]) -> None: + """Verify a lease whose caller gave up is released once it is made.""" + pool = SourcePool() + lease = asyncio.ensure_future(pool.lease("high", Pull)) + await asyncio.sleep(0) + lease.cancel() + with pytest.raises(asyncio.CancelledError): + await lease + for _ in range(20): + if sources and sources[0].closed: + break + await asyncio.sleep(0) + + assert sources[0].close_reasons == ["no sessions left"] + assert pool.counts() == {} + + +async def test_source_that_ends_is_forgotten(sources: list[Source]) -> None: + """Verify an ended source stops the pull and the next lease opens anew.""" + pool = SourcePool() + pull = Pull() + lease = await pool.lease("high", lambda: pull) + idle = await pool.lease("high", Pull) + await lease.activate() + + source = sources[0] + assert source.on_closed is not None + source.on_closed("worker exited") + for _ in range(20): + if not pull.running: + break + await asyncio.sleep(0) + + assert not pull.running + assert pool.counts() == {} + with pytest.raises(MediaError, match="closed"): + await idle.activate() + other = await pool.lease("high", Pull) + assert other.source is not source + for done in (lease, idle, other): + await done.release() + + +async def test_close_stops_pulls_and_sources(sources: list[Source]) -> None: + """Verify closing the pool stops every pull and closes every source.""" + pool = SourcePool() + pulls = {"high": Pull(), "low": Pull()} + leases = [await pool.lease(key, lambda key=key: pulls[key]) for key in pulls] + for lease in leases: + await lease.activate() + + await pool.close() + + assert not any(pull.running for pull in pulls.values()) + assert [source.close_reasons for source in sources] == [["pool closed"]] * 2 + assert pool.counts() == {} diff --git a/tests/test_media_rtp.py b/tests/test_media_rtp.py index d7eb7262..9aec7b18 100644 --- a/tests/test_media_rtp.py +++ b/tests/test_media_rtp.py @@ -89,6 +89,17 @@ def test_parse_compound_sr_sdes_bye() -> None: assert messages[2].media_ssrcs == [9, 10] +def test_parse_rtcp_bye_without_sources() -> None: + """Test a BYE without sources is parsed and does not end the compound.""" + bye = bytes([0x80, rtp.RTCP_BYE, 0, 0]) + pli = rtp.build_pli(1, 2) + messages = rtp.parse_rtcp(bye + pli) + assert [m.packet_type for m in messages] == [rtp.RTCP_BYE, rtp.RTCP_PSFB] + assert messages[0].media_ssrcs == [] + # Any other empty part is malformed. + assert rtp.parse_rtcp(bytes([0x80, rtp.RTCP_SR, 0, 0]) + pli) == [] + + def test_parse_rtcp_stops_at_garbage() -> None: """Verify parsing stops at a part with a bad version or length.""" assert rtp.parse_rtcp(rtp.build_pli(1, 2) + b"\x00" * 8) == rtp.parse_rtcp( @@ -98,16 +109,17 @@ def test_parse_rtcp_stops_at_garbage() -> None: @pytest.mark.parametrize( - "data", + ("data", "parsed"), [ - bytes([0x81, 206, 0, 0]) + bytes(8), - bytes([0x80, 203, 0, 0, 0x80, 200, 0, 0]), + (bytes([0x81, 206, 0, 0]) + bytes(8), 0), + (bytes([0x81, 203, 0, 0]), 0), + (bytes([0x80, 203, 0, 0, 0x80, 200, 0, 0]), 1), ], - ids=["zero-length-pli", "zero-length-bye-then-sr"], + ids=["zero-length-pli", "zero-length-bye-with-count", "bye-then-empty-sr"], ) -def test_parse_rtcp_rejects_zero_length(data: bytes) -> None: +def test_parse_rtcp_rejects_zero_length(data: bytes, parsed: int) -> None: """Verify a part without room for the sender SSRC stops parsing.""" - assert rtp.parse_rtcp(data) == [] + assert len(rtp.parse_rtcp(data)) == parsed @pytest.mark.parametrize( diff --git a/tests/test_media_worker.py b/tests/test_media_worker.py index 38741f1a..7c076c1a 100644 --- a/tests/test_media_worker.py +++ b/tests/test_media_worker.py @@ -83,18 +83,28 @@ def kill(worker: MediaWorker) -> None: class Output: """Collects what the worker side writes to its output pipe.""" - def __init__(self) -> None: - """Start empty.""" + def __init__(self, buffered: int = 0) -> None: + """Start empty, with ``buffered`` bytes the parent has not read.""" self.data = b"" + self.buffered = buffered + self.closed = False def is_closing(self) -> bool: - """The pipe stays open.""" - return False + """Whether the pipe was closed.""" + return self.closed + + def get_write_buffer_size(self) -> int: + """The bytes the parent has not read.""" + return self.buffered def write(self, data: bytes) -> None: """Keep the frame.""" self.data += data + def close(self) -> None: + """Close the pipe.""" + self.closed = True + async def unserializable(message: dict[str, Any]) -> dict[str, Any]: """A request handler whose result cannot be encoded.""" @@ -110,3 +120,44 @@ async def test_unencodable_answer_becomes_an_error() -> None: message, _blob = await read_message(reader_of(output.data)) assert message["id"] == 7 assert message["error"].startswith("Answer not sent") + + +async def test_slow_parent_loses_log_records_first() -> None: + """Verify a backed up pipe drops log records but still sends events.""" + output = Output(buffered=_worker.LOG_DROP_BUFFER + 1) + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server.send({"event": "log", "level": 30, "name": "pyhap", "message": "x"}) + assert output.data == b"" + server.send({"event": "source_closed", "source": 1, "reason": "x"}) + message, _blob = await read_message(reader_of(output.data)) + assert message["event"] == "source_closed" + + +async def test_stalled_parent_stops_the_worker() -> None: + """Verify the worker gives up on a parent that stopped reading.""" + output = Output(buffered=_worker.MAX_OUTPUT_BUFFER + 1) + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server.send({"event": "source_closed", "source": 1, "reason": "x"}) + assert output.data == b"" + assert output.closed + assert server.stopping.is_set() + + +async def test_worker_crash_ends_its_sources(worker: MediaWorker) -> None: + """Verify a crashed worker closes its sources and the next use restarts it.""" + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + pid = worker.pid + + kill(worker) + for _ in range(200): + if reasons: + break + await asyncio.sleep(0.01) + + assert source.closed + assert len(reasons) == 1 + again = await worker.open_source() + assert worker.pid != pid + assert not again.closed From 58a03568425a7e415f2755bb17ff29219811b2b4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:40:18 +0100 Subject: [PATCH 48/56] Test a closed connection without an accessory --- tests/test_accessory_driver.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 237f439a..f48085e9 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1446,6 +1446,16 @@ def test_prepare_bounds_prepared_writes(driver: AccessoryDriver) -> None: CLIENT = ("1.2.3.4", 5) +def test_connection_lost_without_accessory(driver: AccessoryDriver) -> None: + """Test a closed connection before an accessory was added is fine.""" + assert driver.accessory is None + driver.prepared_writes[CLIENT] = {1: 0.0} + + driver.connection_lost(CLIENT) + + assert CLIENT not in driver.prepared_writes + + def test_connection_lost_tells_the_accessory(driver: AccessoryDriver) -> None: """Test the accessory hears about a closed connection.""" acc = Accessory(driver, "TestAcc") From 8a0c11e2b640abc6efb4b71a47ce5fe83c000a33 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:40:18 +0100 Subject: [PATCH 49/56] Normalize Setup Endpoints addresses and keep set_endpoints returning None An IPv6 address in another notation, e.g. upper case or without ::, is accepted in its canonical form instead of being refused. Only a scope ID, the wrong IP version or an invalid address is refused. set_endpoints sets the refusal as the value and returns nothing, as before. --- pyhap/camera.py | 20 ++++++++------------ tests/test_camera.py | 26 ++++++++++++-------------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index 27b55f93..889a33b9 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -253,8 +253,9 @@ def parse_setup_address(value: bytes, is_ipv6: bool) -> str: """Return the controller address of a Setup Endpoints write. - Only the canonical IP of the announced version is accepted, without an - IPv6 scope ID. + The address must be an IP address of the announced version, without an + IPv6 scope ID. It is returned in canonical form, e.g. ``fd00::1`` for + ``FD00:0::1``. :raises ValueError: If the address is anything else. """ @@ -265,11 +266,9 @@ def parse_setup_address(value: bytes, is_ipv6: bool) -> str: raise ValueError(f"invalid address {value!r}") from ex if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None: raise ValueError(f"address {address!r} has a scope ID") - if str(parsed) != address: - raise ValueError(f"address {address!r} is not in canonical form") if parsed.version != (6 if is_ipv6 else 4): raise ValueError(f"address {address!r} does not match its IP version") - return address + return str(parsed) def _decode_setup_endpoints( @@ -1342,12 +1341,12 @@ def set_streaming_available(self, stream_idx: int) -> None: self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"] self._management[stream_idx].get_characteristic("StreamingStatus").notify() - def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: + def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: """Configure streaming endpoints. Called when iOS sets the SetupEndpoints ``Characteristic``. The endpoint information for the camera should be set as the current value of SetupEndpoints. - A refused write sets and returns the error answer. + A refused write sets the error answer. :param value: The base64-encoded stream session details in TLV format. :param value: ``str`` @@ -1369,13 +1368,10 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: ) = _decode_setup_endpoints(session_id, objs) except (IndexError, KeyError, ValueError, struct.error) as ex: logger.warning("[%s] Refusing the endpoints: %s", session_id, ex) - refusal = _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) - # The value for a driver without read back, the return value for - # one with it. self._management[stream_idx].get_characteristic("SetupEndpoints").set_value( - refusal + _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) ) - return refusal + return # Configure the SetupEndpoints response diff --git a/tests/test_camera.py b/tests/test_camera.py index 0c237d93..3a7cc146 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -492,23 +492,25 @@ def _setup_request_with(address_tlv: bytes, crypto: bytes = b"\x00") -> str: @pytest.mark.parametrize( - ("address", "is_ipv6"), + ("address", "is_ipv6", "expected"), [ - (b"192.168.1.114", False), - (b"fd00::1", True), + (b"192.168.1.114", False, "192.168.1.114"), + (b"fd00::1", True, "fd00::1"), + (b"FD00::1", True, "fd00::1"), + (b"fd00:0:0:0:0:0:0:1", True, "fd00::1"), ], ) def test_setup_endpoints_accepts_addresses( - mock_driver: Any, address: bytes, is_ipv6: bool + mock_driver: Any, address: bytes, is_ipv6: bool, expected: str ) -> None: - """Test canonical addresses of the announced version are accepted.""" + """Test addresses of the announced version are accepted in canonical form.""" acc = camera.Camera(_OPTIONS, mock_driver, "Camera") management = acc.get_service("CameraRTPStreamManagement") assert management is not None management.get_characteristic("SetupEndpoints").client_update_value( _setup_request(address, is_ipv6) ) - assert acc.sessions[_SESSION_ID]["address"] == address.decode() + assert acc.sessions[_SESSION_ID]["address"] == expected @pytest.mark.parametrize( @@ -517,8 +519,6 @@ def test_setup_endpoints_accepts_addresses( (b"192.168.1.114 x", False), (b"192.168.1.114\n", False), (b"fe80::1%en0", True), - (b"FD00::1", True), - (b"fd00:0:0:0:0:0:0:1", True), (b"192.168.1.114", True), (b"fd00::1", False), (b"::ffff:192.168.1.114", False), @@ -534,13 +534,12 @@ def test_setup_endpoints_refuses_addresses( management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - response = setup_endpoints.client_update_value(_setup_request(address, is_ipv6)) + setup_endpoints.client_update_value(_setup_request(address, is_ipv6)) assert acc.sessions == {} - assert tlv.decode(response, from_base64=True) == { + assert tlv.decode(setup_endpoints.get_value(), from_base64=True) == { camera.SETUP_TYPES["SESSION_ID"]: _SESSION_ID.bytes, camera.SETUP_TYPES["STATUS"]: camera.SETUP_STATUS["ERROR"], } - assert setup_endpoints.get_value() == response @pytest.mark.parametrize( @@ -580,10 +579,9 @@ def test_setup_endpoints_refuses_malformed( management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - response = setup_endpoints.client_update_value(request_value) + setup_endpoints.client_update_value(request_value) assert acc.sessions == {} - assert tlv.decode(response, from_base64=True) == { + assert tlv.decode(setup_endpoints.get_value(), from_base64=True) == { camera.SETUP_TYPES["SESSION_ID"]: _SESSION_ID.bytes, camera.SETUP_TYPES["STATUS"]: camera.SETUP_STATUS["ERROR"], } - assert setup_endpoints.get_value() == response From 770db1795e0bc76e20f63aeac09d1ba3d3ad0cde Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:50:39 +0100 Subject: [PATCH 50/56] Run test_start_from_sync on the driver's own loop It set a new default executor on the shared test loop and shut it down when the driver stopped, so later tests on the same xdist worker failed with "cannot schedule new futures after shutdown". The driver now makes its own loop and executor, as a sync start does. --- tests/test_accessory_driver.py | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 8443632b..63caf360 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -1,7 +1,6 @@ """Tests for pyhap.accessory_driver.""" import asyncio -from concurrent.futures import ThreadPoolExecutor from functools import partial import os import tempfile @@ -1280,24 +1279,33 @@ def fail_callback(*_: Any) -> NoReturn: } -def test_start_from_sync(driver: AccessoryDriver) -> None: - """Start from sync.""" +def test_start_from_sync(async_zeroconf: Any) -> None: + """Start from sync, on the loop and executor the driver makes itself. - class Acc(Accessory): - @Accessory.run_at_interval(0) - async def run(self) -> None: - driver.executor = ThreadPoolExecutor() - driver.loop.set_default_executor(driver.executor) - await driver.async_stop() + Not the shared test loop: stopping shuts the executor down, which would + leave that loop unable to run later tests' executor jobs. + """ + with ( + patch("pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock), + patch("pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock), + patch("pyhap.accessory_driver.AccessoryDriver.persist"), + ): + driver = AccessoryDriver() + + class Acc(Accessory): + @Accessory.run_at_interval(0) + async def run(self) -> None: + await driver.async_stop() - def setup_message(self) -> None: - pass + def setup_message(self) -> None: + pass - acc = Acc(driver, "TestAcc") - driver.add_accessory(acc) - with patch.object(driver.loop, "close"): + acc = Acc(driver, "TestAcc") + driver.add_accessory(acc) driver.start() + assert driver.loop.is_closed() + def test_accessory_level_callbacks(driver: AccessoryDriver) -> None: bridge = Bridge(driver, "mybridge") From 3b2e24dde3aff8bfc2dae5a79952d455a915115a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:11:53 +0100 Subject: [PATCH 51/56] Name Python 3.11 as the minimum in the install guide --- docs/source/intro/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/intro/install.rst b/docs/source/intro/install.rst index 0267da94..b1e3b261 100644 --- a/docs/source/intro/install.rst +++ b/docs/source/intro/install.rst @@ -7,7 +7,7 @@ Installation Guide Before We Begin =============== -HAP-python requires Python 3.4+. +HAP-python requires Python 3.11+. This guide will cover the current version of Raspbian and Ubuntu LTS. It is somewhat safe to assume the process for newer versions of Ubuntu will work. From 18cbcbbef6f74f2a914735af91b8a4ca4a180139 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:16:28 +0100 Subject: [PATCH 52/56] Answer a Setup Endpoints refused while streaming is off only to the writer --- pyhap/camera.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index 1554e347..70675bb2 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -1678,16 +1678,14 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]]) if not self.streaming_allowed: logger.info("[%s] Streaming is turned off, refusing it", session_id) - self._management[stream_idx].get_characteristic("SetupEndpoints").set_value( - tlv.encode( - SETUP_TYPES["SESSION_ID"], - session_id.bytes, - SETUP_TYPES["STATUS"], - SETUP_STATUS["ERROR"], - to_base64=True, - ) + # Like an answer, the refusal is only for the writer. + return tlv.encode( + SETUP_TYPES["SESSION_ID"], + session_id.bytes, + SETUP_TYPES["STATUS"], + SETUP_STATUS["ERROR"], + to_base64=True, ) - return # Extract address info address_tlv = objs[SETUP_TYPES["ADDRESS"]] From c2e659ad7a24a2a7253a89bb5df8b509ed039564 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:22:34 +0100 Subject: [PATCH 53/56] Test that a refused Setup Endpoints reaches only the writer --- tests/test_camera.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_camera.py b/tests/test_camera.py index 9a4a9a3b..c6dfaae7 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -622,6 +622,50 @@ def test_setup_endpoints_refuses_malformed( } +@pytest.mark.parametrize( + ("address", "camera_active"), + [ + (b"192.168.1.114 x", 1), + (b"192.168.1.114", 0), + ], + ids=["invalid_address", "streaming_off"], +) +def test_setup_endpoints_refusal_read_back_by_writer_only( + driver: Any, address: bytes, camera_active: int +) -> None: + """Test only the controller whose write was refused reads the refusal.""" + acc = camera.Camera({**_OPTIONS, "global_operating_mode": []}, driver, "Camera") + driver.add_accessory(acc) + operating_mode = acc.get_service("CameraGlobalOperatingMode") + assert operating_mode is not None + operating_mode.get_characteristic("HomeKitCameraActive").client_update_value( + camera_active + ) + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + char = management.get_characteristic("SetupEndpoints") + iid = acc.iid_manager.get_iid(char) + request = _setup_request(address, False) + query = {"characteristics": [{"aid": acc.aid, "iid": iid, "value": request}]} + + assert driver.set_characteristics(query, ("192.168.1.114", 50000)) is None + + char_id = f"{acc.aid}.{iid}" + (writer,) = driver.get_characteristics([char_id], ("192.168.1.114", 50000))[ + "characteristics" + ] + (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ + "characteristics" + ] + assert acc.sessions == {} + assert tlv.decode(writer["value"], from_base64=True) == { + camera.SETUP_TYPES["SESSION_ID"]: _SESSION_ID.bytes, + camera.SETUP_TYPES["STATUS"]: camera.SETUP_STATUS["ERROR"], + } + assert other["value"] == "" + assert char.value == "" + + def test_setup_endpoints_set_by_subclass_read_back_by_writer_only( driver: Any, ) -> None: From 874e697446b41b2ef0efdc6fd629979c1cdcf8c2 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:33:40 +0100 Subject: [PATCH 54/56] Decode the stream service UUIDs with UUID(bytes=...) like the zones --- pyhap/hksv3.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/pyhap/hksv3.py b/pyhap/hksv3.py index f276f3c9..713eb715 100644 --- a/pyhap/hksv3.py +++ b/pyhap/hksv3.py @@ -131,11 +131,6 @@ def _to_enum(enum_type: type[_E], data: bytes) -> _E | int: return value -def _to_uuid(data: bytes) -> UUID: - # UUID raises ValueError for anything but 16 bytes. - return UUID(bytes=data) - - @dataclass class VideoStreamCapability: """A video encoding the camera can produce from a sensor, bit rates in kbps.""" @@ -453,7 +448,7 @@ class RTPStreamingControl: def decode(cls, data: bytes) -> RTPStreamingControl: fields = _fields(data) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), _to_enum(RTPStreamingCommand, _required(fields, 2)), _optional_int(fields, 3), _optional_int(fields, 4), @@ -576,7 +571,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> SetupEndpoints: fields = _fields(data) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), EndpointAddress.decode(_required(fields, 3)), SRTPParameters.decode(_required(fields, 4)), SRTPParameters.decode(_required(fields, 5)), @@ -624,7 +619,7 @@ def ssrc(value: int | None) -> bytes | None: def decode(cls, data: bytes) -> SetupEndpointsResponse: fields = _fields(data) return cls( - _optional(fields, 1, _to_uuid), + _optional(fields, 1, lambda data: UUID(bytes=data)), _to_enum(SetupEndpointsStatus, _required(fields, 2)), _optional(fields, 3, EndpointAddress.decode), _optional(fields, 4, SRTPParameters.decode), @@ -944,7 +939,7 @@ def decode(cls, data: bytes) -> WebRTCSolicitOfferResponse: fields = _fields(data) return cls( _to_enum(WebRTCSolicitOfferStatus, _required(fields, 4)), - _optional(fields, 1, _to_uuid), + _optional(fields, 1, lambda data: UUID(bytes=data)), _optional(fields, 2, _to_str), _candidates(fields), _optional(fields, 5, SFrameKey.decode), @@ -970,7 +965,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> WebRTCProvideAnswer: fields = _fields(data) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), _to_sdp(_required(fields, 2)), _candidates(fields), ) @@ -994,7 +989,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> WebRTCSessionStatus: fields = _fields(data) return cls( - _optional(fields, 1, _to_uuid), + _optional(fields, 1, lambda data: UUID(bytes=data)), _to_enum(WebRTCStreamingStatus, _required(fields, 2)), ) @@ -1015,7 +1010,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> WebRTCStreamingControl: fields = _fields(data) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), _to_enum(WebRTCStreamingCommand, _required(fields, 2)), ) @@ -1039,7 +1034,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> WebRTCReoffer: fields = _fields(data) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), _to_sdp(_required(fields, 2)), _options(fields, 3), ) @@ -1071,7 +1066,7 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> WebRTCReofferResponse: fields = _fields(data) return cls( - _optional(fields, 1, _to_uuid), + _optional(fields, 1, lambda data: UUID(bytes=data)), _to_enum(WebRTCStreamingStatus, _required(fields, 3)), _optional(fields, 2, _to_str), _optional(fields, 4, SFrameKey.decode), @@ -1106,7 +1101,7 @@ def decode(cls, data: bytes) -> WebRTCUpdateSession: f" at most {MAX_SFRAME_KEY_CHANGES} each" ) return cls( - _to_uuid(_required(fields, 1)), + UUID(bytes=_required(fields, 1)), [SFrameKey.decode(key) for key in keys], [_to_int(_required(_fields(kid), 1)) for kid in kids], ) From b28c8c827358f838963669149da44985d0fe2aae Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:34:08 +0100 Subject: [PATCH 55/56] Keep setting the Setup Endpoints answer as the value A subclass that wraps set_endpoints, calls super() and returns nothing got no answer once set_endpoints only returned it. set_endpoints sets the answer, or a refusal, as the value again and returns it. The driver keeps what a read back setter leaves as the value for the writer only and puts the shared value back. Neither the former value of a refused write nor the request a setter left alone counts as an answer. An async setter must return the answer, a value it sets itself is shared. Correct the write response docstring for a write without r, type the Setup Endpoints helpers, document read_back_response and share the Setup Endpoints request of the camera tests. --- pyhap/accessory_driver.py | 19 +-- pyhap/camera.py | 38 +++-- pyhap/characteristic.py | 7 + tests/test_accessory_driver.py | 37 +++++ tests/test_camera.py | 249 ++++++++++++++++++--------------- 5 files changed, 219 insertions(+), 131 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index b3719d0e..032c4286 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1368,13 +1368,16 @@ def _run_characteristic_writes( # client_update_value stored the request as the value, so put # back the former one unless the setter set a value of its own. # Whatever the setter of a read back characteristic leaves in the - # value is an answer for the writer only, e.g. from a subclass that - # still sets the Setup Endpoints answer itself. + # value is an answer for the writer only, e.g. the Setup Endpoints + # answer of a subclass that calls super() and returns nothing. + # Neither the former value of a refused write nor the request the + # setter left alone is an answer. if private_write: own_value = char.value if char.read_back_response: - if set_result_value is None and own_value != _stored_value( - char, value + if set_result_value is None and own_value not in ( + previous_value, + _stored_value(char, value), ): set_result_value = own_value char.value = previous_value @@ -1543,10 +1546,10 @@ def _store_write_response( characteristic is readable, only the connection that wrote reads the response back, until it closes, and it wins over the getter and a later ``set_value``. Other connections read the characteristic's own value, - which the write leaves as it was, also for a write without ``r``. A - characteristic that also has ``ev`` notifies the written value as any - write does. A read back characteristic keeps the response the same - way, without a write response. + which the write leaves as it was. A write without ``r`` stays readable + for everyone and notifies ``ev`` subscribers as any write does, unless + the characteristic has ``read_back_response``, which keeps the response + the same way without a write response. """ readable = HAP_PERMISSION_READ in char.properties[PROP_PERMISSIONS] try: diff --git a/pyhap/camera.py b/pyhap/camera.py index 6a0ac605..8de494d0 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -7,8 +7,8 @@ [0. Read the current streaming status] 1. Sets the SetupEndpoints characteristic to notify the camera about its IP address, selected security parameters, etc. -2. The camera responds to the above by setting the SetupEndpoints with its IP address, -etc. +2. The camera answers the write with its IP address, etc., and the client reads the +answer back from SetupEndpoints. 3. The client sets the SelectedRTPStreamConfiguration characteristic to notify the camera of its prefered audio and video configuration and to initiate the start of the streaming. @@ -349,7 +349,7 @@ def _decode_setup_endpoints( ) -def _setup_endpoints_status(session_id: UUID, status: bytes) -> Any: +def _setup_endpoints_status(session_id: UUID, status: bytes) -> str: """Encode a Setup Endpoints answer that has only a status.""" return tlv.encode( SETUP_TYPES["SESSION_ID"], @@ -1750,15 +1750,22 @@ def set_streaming_available(self, stream_idx: int) -> None: self._streaming_status[stream_idx] = STREAMING_STATUS["AVAILABLE"] self._management[stream_idx].get_characteristic("StreamingStatus").notify() - def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: + def set_endpoints(self, value: str, stream_idx: int | None = None) -> str: """Configure streaming endpoints. - Called when iOS sets the SetupEndpoints ``Characteristic``. Returns the - endpoint information of the camera, which only the connection that - wrote reads back. + Called when iOS sets the SetupEndpoints ``Characteristic``. Sets the + endpoint information of the camera, or a refusal, as the value of + SetupEndpoints and returns it. A subclass that calls ``super()`` and + returns nothing still answers. For a controller's write the driver + keeps the answer for the connection that wrote and puts the former + value back. An async setter must return the answer and must not set + the value itself, a value it sets is shared with every controller. A + direct ``client_update_value`` leaves the answer as the value. :param value: The base64-encoded stream session details in TLV format. :param value: ``str`` + :return: The base64-encoded answer in TLV format. + :rtype: ``str`` """ if stream_idx is None: stream_idx = 0 @@ -1777,11 +1784,15 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: except (IndexError, KeyError, ValueError, struct.error) as ex: logger.warning("[%s] Refusing the endpoints: %s", session_id, ex) # Like an answer, the refusal is only for the writer. - return _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) + return self._answer_endpoints( + stream_idx, _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) + ) if not self.streaming_allowed: logger.info("[%s] Streaming is turned off, refusing it", session_id) - return _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) + return self._answer_endpoints( + stream_idx, _setup_endpoints_status(session_id, SETUP_STATUS["ERROR"]) + ) # Configure the SetupEndpoints response @@ -1879,7 +1890,14 @@ def set_endpoints(self, value: str, stream_idx: int | None = None) -> Any: "audio_backchannel": audio_backchannel, } - return response_tlv + return self._answer_endpoints(stream_idx, response_tlv) + + def _answer_endpoints(self, stream_idx: int, answer: str) -> str: + """Set the Setup Endpoints answer as the value and return it.""" + self._management[stream_idx].get_characteristic("SetupEndpoints").set_value( + answer, should_notify=False + ) + return answer async def run(self) -> None: """Start the HDS listener when recording transport is available.""" diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index 21ee2299..e5739cd1 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -148,6 +148,13 @@ class Characteristic: the current temperature. Characteristics are contained in services. Each characteristic has a unique type UUID and a set of properties, like format, min and max values, valid values and others. + + Set ``read_back_response`` to True for a readable control point that a + controller writes and then reads, such as Setup Endpoints: what the + setter returns or leaves as the value is read back only by the connection + that wrote, and the shared value stays as it was. An async setter must + return the answer and must not set the value itself, a value it sets is + shared with every controller. """ __slots__ = ( diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 47499589..13251a16 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -2959,6 +2959,23 @@ def test_read_back_response_only_for_writer( assert acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS][0][HAP_REPR_VALUE] == 0 +def test_read_back_prefers_the_returned_answer(driver: AccessoryDriver) -> None: + """The answer a setter returns wins over a value it sets itself.""" + acc, char = _add_read_back_char(driver) + + def _setter(value: int) -> int: + char.set_value(42) + return 7 + + char.setter_callback = _setter + + assert _write(driver, acc, char, **{HAP_REPR_VALUE: 5}) is None + + char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" + assert _read(driver, char_id, WRITER) == 7 + assert _read(driver, char_id, OTHER_CONTROLLER) == 0 + + @pytest.mark.parametrize( ("reader", "expected"), [ @@ -2998,6 +3015,26 @@ async def test_async_read_back_response(driver: AccessoryDriver) -> None: assert _read(driver, char_id, OTHER_CONTROLLER) == 0 +@pytest.mark.parametrize( + ("value", "setter"), + [ + pytest.param("notanint", MagicMock(return_value=None), id="invalid-value"), + pytest.param(5, MagicMock(return_value=None), id="setter-does-nothing"), + ], +) +def test_read_back_without_answer_keeps_nothing_for_writer( + driver: AccessoryDriver, value: Any, setter: MagicMock +) -> None: + """Neither the former value nor the request is kept as the writer's answer.""" + acc, char = _add_read_back_char(driver) + char.setter_callback = setter + + _write(driver, acc, char, **{HAP_REPR_VALUE: value}) + char.set_value(3) + + assert _read(driver, f"{acc.aid}.{acc.iid_manager.get_iid(char)}", WRITER) == 3 + + CLIENT = ("1.2.3.4", 5) diff --git a/tests/test_camera.py b/tests/test_camera.py index c9acf676..e0ff83a2 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -7,13 +7,20 @@ import socket import struct from typing import Any, cast -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from uuid import UUID import pytest from pyhap import camera, tlv +# A Setup Endpoints request of a controller at 192.168.1.114. +_SETUP_ENDPOINTS_REQUEST = ( + "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" + "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" + "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" +) + _OPTIONS = { "stream_count": 4, "video": { @@ -75,12 +82,6 @@ def test_init(mock_driver: Any) -> None: def test_setup_endpoints(mock_driver: Any) -> None: """Test that the SetupEndpoint response is computed correctly""" - set_endpoint_req = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - set_endpoint_res = ( "ARCszGzBBWNFFY2pdLRQkAaRAgEAAxoBAQACDTE5Mi4xNjguMS4yMjYDAjPFBAK" "s1gQlAQEAAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QUlAQEAAh" @@ -91,33 +92,29 @@ def test_setup_endpoints(mock_driver: Any) -> None: management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - response = setup_endpoints.client_update_value(set_endpoint_req) + response = setup_endpoints.client_update_value(_SETUP_ENDPOINTS_REQUEST) assert response[:171] == set_endpoint_res[:171] + # Without the driver nothing puts the former value back. + assert setup_endpoints.get_value() == response def test_setup_endpoints_does_not_log_srtp_keys( mock_driver: Any, caplog: pytest.LogCaptureFixture ) -> None: """Verify the negotiated SRTP key material is not written to the logs.""" - set_endpoint_req = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - acc = camera.Camera(_OPTIONS, mock_driver, "Camera") management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") with caplog.at_level(logging.DEBUG, logger="pyhap.camera"): - setup_endpoints.client_update_value(set_endpoint_req) + setup_endpoints.client_update_value(_SETUP_ENDPOINTS_REQUEST) # The endpoint configuration was logged at DEBUG level, but without any # of the SRTP master key/salt material in it. assert "Received endpoint configuration:" in caplog.text - key_and_salt = _audio_srtp_key_and_salt(set_endpoint_req) + key_and_salt = _audio_srtp_key_and_salt(_SETUP_ENDPOINTS_REQUEST) assert base64.b64encode(key_and_salt).decode() not in caplog.text assert key_and_salt.hex() not in caplog.text @@ -186,19 +183,13 @@ def test_no_talkback_by_default(mock_driver: Any) -> None: SetupEndpoints keeps echoing the controller's port back (unchanged, pre-existing behavior for accessories that don't opt in). """ - set_endpoint_req = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - acc = camera.Camera(_OPTIONS, mock_driver, "Camera") assert acc.get_service("Speaker") is None management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - setup_endpoints.client_update_value(set_endpoint_req) + setup_endpoints.client_update_value(_SETUP_ENDPOINTS_REQUEST) session_id = list(acc.sessions)[0] assert acc.sessions[session_id]["audio_backchannel"] is None @@ -260,18 +251,12 @@ def test_setup_endpoints_reports_real_port_when_talkback_enabled( this module fixes: HAP clients had nowhere valid to send the talkback audio to, because the accessory never actually listened anywhere). """ - set_endpoint_req = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - options = dict(_OPTIONS, talkback=True) acc = camera.Camera(options, mock_driver, "Camera") management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - response = setup_endpoints.client_update_value(set_endpoint_req) + response = setup_endpoints.client_update_value(_SETUP_ENDPOINTS_REQUEST) session_id = list(acc.sessions)[0] receiver = acc.sessions[session_id]["audio_backchannel"] @@ -279,7 +264,7 @@ def test_setup_endpoints_reports_real_port_when_talkback_enabled( reported_port = _accessory_audio_port(response) assert reported_port == receiver.local_port - assert reported_port != _controller_audio_port(set_endpoint_req) + assert reported_port != _controller_audio_port(_SETUP_ENDPOINTS_REQUEST) receiver.stop() @@ -300,18 +285,12 @@ class TalkbackCamera(camera.Camera): def talkback_audio_received(self, session_id: Any, payload: Any) -> None: received.append((session_id, payload)) - set_endpoint_req = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - options = dict(_OPTIONS, talkback=True) acc = TalkbackCamera(options, mock_driver, "Camera") management = acc.get_service("CameraRTPStreamManagement") assert management is not None setup_endpoints = management.get_characteristic("SetupEndpoints") - setup_endpoints.client_update_value(set_endpoint_req) + setup_endpoints.client_update_value(_SETUP_ENDPOINTS_REQUEST) session_id = list(acc.sessions)[0] receiver = acc.sessions[session_id]["audio_backchannel"] @@ -320,7 +299,7 @@ def talkback_audio_received(self, session_id: Any, payload: Any) -> None: # controller is local so the loopback send passes the source filter. receiver._controller_address = "127.0.0.1" - key_and_salt = _audio_srtp_key_and_salt(set_endpoint_req) + key_and_salt = _audio_srtp_key_and_salt(_SETUP_ENDPOINTS_REQUEST) rtp_payload = b"opus-frame-payload" rtp_packet = struct.pack("!BBHII", 0x80, 110, 1, 0, 0x1234) + rtp_payload @@ -423,80 +402,6 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: exec_mock.assert_called_once() -def test_setup_endpoints_read_back_by_writer_only(driver: Any) -> None: - """Test only the controller that set up a session reads its answer.""" - acc = camera.Camera(_OPTIONS, driver, "Camera") - driver.add_accessory(acc) - management = acc.get_service("CameraRTPStreamManagement") - assert management is not None - char = management.get_characteristic("SetupEndpoints") - char_id = f"{acc.aid}.{acc.iid_manager.get_iid(char)}" - request = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - query = { - "characteristics": [ - {"aid": acc.aid, "iid": acc.iid_manager.get_iid(char), "value": request} - ] - } - - assert driver.set_characteristics(query, ("192.168.1.114", 50000)) is None - - (writer,) = driver.get_characteristics([char_id], ("192.168.1.114", 50000))[ - "characteristics" - ] - (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ - "characteristics" - ] - # The writer reads the answer, which carries a status, not its request. - answer = tlv.decode(writer["value"], from_base64=True) - assert answer[b"\x01"] == tlv.decode(request, from_base64=True)[b"\x01"] - assert answer[b"\x02"] == b"\x00" - assert other["value"] == "" - assert char.value == "" - - -def test_setup_endpoints_set_by_subclass_read_back_by_writer_only( - driver: Any, -) -> None: - """Test an answer a subclass sets as the value stays with the writer.""" - - class _Camera(camera.Camera): - def set_endpoints(self, value: str, stream_idx: int | None = None) -> None: - answer = super().set_endpoints(value, stream_idx) - management = self.get_service("CameraRTPStreamManagement") - assert management is not None - management.get_characteristic("SetupEndpoints").set_value(answer) - - acc = _Camera(_OPTIONS, driver, "Camera") - driver.add_accessory(acc) - management = acc.get_service("CameraRTPStreamManagement") - assert management is not None - char = management.get_characteristic("SetupEndpoints") - iid = acc.iid_manager.get_iid(char) - request = ( - "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" - "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" - "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" - ) - query = {"characteristics": [{"aid": acc.aid, "iid": iid, "value": request}]} - - assert driver.set_characteristics(query, ("192.168.1.114", 50000)) is None - - char_id = f"{acc.aid}.{iid}" - (writer,) = driver.get_characteristics([char_id], ("192.168.1.114", 50000))[ - "characteristics" - ] - (other,) = driver.get_characteristics([char_id], ("192.168.1.115", 50000))[ - "characteristics" - ] - assert tlv.decode(writer["value"], from_base64=True)[b"\x02"] == b"\x00" - assert other["value"] == "" - assert char.value == "" - - _SESSION_ID = UUID("acccc6cc-1505-6345-158d-a974b4509006") @@ -695,6 +600,124 @@ def test_setup_endpoints_refuses_address_before_streaming_off( } +_WRITER = ("192.168.1.114", 50000) +_OTHER_CONTROLLER = ("192.168.1.115", 50000) + + +class _WrappingCamera(camera.Camera): + """A subclass that wraps set_endpoints and returns nothing.""" + + def set_endpoints( # ty: ignore[invalid-method-override] + self, value: str, stream_idx: int | None = None + ) -> None: + super().set_endpoints(value, stream_idx) + + +class _ReturningCamera(camera.Camera): + """A subclass that wraps set_endpoints and returns the answer.""" + + def set_endpoints(self, value: str, stream_idx: int | None = None) -> str: + return super().set_endpoints(value, stream_idx) + + +class _SettingCamera(camera.Camera): + """A subclass that sets the answer itself, as before, and returns nothing.""" + + def set_endpoints( # ty: ignore[invalid-method-override] + self, value: str, stream_idx: int | None = None + ) -> None: + answer = super().set_endpoints(value, stream_idx) + _setup_endpoints_char(self).set_value(answer) + + +def _setup_endpoints_char(acc: camera.Camera) -> Any: + """Return the Setup Endpoints characteristic of the first stream.""" + management = acc.get_service("CameraRTPStreamManagement") + assert management is not None + return management.get_characteristic("SetupEndpoints") + + +def _setup_endpoints_query( + acc: camera.Camera, request: str = _SETUP_ENDPOINTS_REQUEST +) -> dict[str, Any]: + """Return a controller's write of a Setup Endpoints request.""" + iid = acc.iid_manager.get_iid(_setup_endpoints_char(acc)) + return {"characteristics": [{"aid": acc.aid, "iid": iid, "value": request}]} + + +def _read_setup_endpoints(driver: Any, acc: camera.Camera, client_addr: Any) -> Any: + """Return the Setup Endpoints value ``client_addr`` reads.""" + iid = acc.iid_manager.get_iid(_setup_endpoints_char(acc)) + (rep,) = driver.get_characteristics([f"{acc.aid}.{iid}"], client_addr)[ + "characteristics" + ] + return rep["value"] + + +@pytest.mark.parametrize( + ("request_value", "streaming_allowed", "status"), + [ + pytest.param( + _SETUP_ENDPOINTS_REQUEST, + True, + camera.SETUP_STATUS["SUCCESS"], + id="answer", + ), + pytest.param( + _setup_request(b"192.168.1.114 x", False), + True, + camera.SETUP_STATUS["ERROR"], + id="malformed", + ), + pytest.param( + _SETUP_ENDPOINTS_REQUEST, + False, + camera.SETUP_STATUS["ERROR"], + id="streaming-off", + ), + ], +) +@pytest.mark.parametrize( + "camera_cls", + [camera.Camera, _WrappingCamera, _ReturningCamera, _SettingCamera], + ids=["camera", "wrapping-subclass", "returning-subclass", "setting-subclass"], +) +def test_setup_endpoints_read_back_by_writer_only( + driver: Any, + camera_cls: type[camera.Camera], + request_value: str, + streaming_allowed: bool, + status: bytes, +) -> None: + """Test only the controller that set up a session reads its answer.""" + acc = camera_cls(_OPTIONS, driver, "Camera") + driver.add_accessory(acc) + char = _setup_endpoints_char(acc) + + with patch.object( + camera.Camera, + "streaming_allowed", + new_callable=PropertyMock, + return_value=streaming_allowed, + ): + assert ( + driver.set_characteristics( + _setup_endpoints_query(acc, request_value), _WRITER + ) + is None + ) + + # The writer reads the answer, which carries a status, not its request. + answer = tlv.decode(_read_setup_endpoints(driver, acc, _WRITER), from_base64=True) + session_id = camera.SETUP_TYPES["SESSION_ID"] + assert answer[session_id] == tlv.decode(request_value, from_base64=True)[session_id] + assert answer[camera.SETUP_TYPES["STATUS"]] == status + assert _read_setup_endpoints(driver, acc, _OTHER_CONTROLLER) == "" + assert char.value == "" + # /accessories shows the shared value. + assert char.to_HAP()["value"] == "" + + @pytest.mark.asyncio async def test_stop_stream_process_already_exited(mock_driver: Any) -> None: """Verify stopping a stream whose ffmpeg already exited does not raise.""" From d20b2234bc83417c63ef2b945d8b78391e6fe16a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:03:36 +0100 Subject: [PATCH 56/56] Run the media worker in a thread on request MediaWorker(in_thread=True) runs the same worker server in a thread of the caller's process, with its own event loop, over a socket pair instead of a child process. The protocol and the proxies stay the same. Loggers are shared in that mode, so nothing is forwarded and their levels are left alone. The process stays the default. --- pyhap/media/_worker.py | 53 +++++-- pyhap/media/worker.py | 222 ++++++++++++++++++++++-------- tests/test_media_worker.py | 4 +- tests/test_media_worker_thread.py | 136 ++++++++++++++++++ 4 files changed, 344 insertions(+), 71 deletions(-) create mode 100644 tests/test_media_worker_thread.py diff --git a/pyhap/media/_worker.py b/pyhap/media/_worker.py index f5e2b447..bddb2f8c 100644 --- a/pyhap/media/_worker.py +++ b/pyhap/media/_worker.py @@ -3,7 +3,8 @@ Started as ``python -m pyhap.media._worker ``, one level per logger in ``FORWARDED_LOGGERS``. Requests arrive framed on stdin; responses and events leave framed on the original stdout, which is moved aside first. -The process ends when stdin closes. +The process ends when stdin closes. In thread mode :func:`serve_socket` runs +the same server over a socket pair instead. """ from __future__ import annotations @@ -12,6 +13,7 @@ import contextlib import logging import os +import socket import sys from typing import Any @@ -148,19 +150,18 @@ async def close_all(self) -> None: source.close("media worker stopped") -async def _serve(output_fd: int) -> None: +async def _serve( + reader: asyncio.StreamReader, + output: asyncio.WriteTransport, + *, + forward_logs: bool, +) -> None: loop = asyncio.get_running_loop() - reader = asyncio.StreamReader() - await loop.connect_read_pipe( - lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(0, "rb", buffering=0) - ) - output, _ = await loop.connect_write_pipe( - asyncio.Protocol, os.fdopen(output_fd, "wb", buffering=0) - ) server = _Server(output) - forwarder = _LogForwarder(server) - for name in FORWARDED_LOGGERS: - logging.getLogger(name).addHandler(forwarder) + forwarder = _LogForwarder(server) if forward_logs else None + if forwarder is not None: + for name in FORWARDED_LOGGERS: + logging.getLogger(name).addHandler(forwarder) server.send({"event": "ready"}) stopping = loop.create_task(server.stopping.wait()) try: @@ -179,6 +180,32 @@ async def _serve(output_fd: int) -> None: stopping.cancel() await server.close_all() output.close() + if forwarder is not None: + for name in FORWARDED_LOGGERS: + logging.getLogger(name).removeHandler(forwarder) + + +async def _serve_stdio(output_fd: int) -> None: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(0, "rb", buffering=0) + ) + output, _ = await loop.connect_write_pipe( + asyncio.Protocol, os.fdopen(output_fd, "wb", buffering=0) + ) + await _serve(reader, output, forward_logs=True) + + +async def serve_socket(sock: socket.socket) -> None: + """Serve one parent over a connected socket, in the parent's process. + + The worker thread of :class:`~pyhap.media.worker.MediaWorker` runs this in + its own event loop. Loggers are shared with the parent, so nothing is + forwarded and their levels stay as the parent sets them. + """ + reader, writer = await asyncio.open_connection(sock=sock) + await _serve(reader, writer.transport, forward_logs=False) def main() -> None: @@ -192,7 +219,7 @@ def main() -> None: logging.getLogger(name).setLevel(level) logging.getLogger(name).propagate = False with contextlib.suppress(KeyboardInterrupt): - asyncio.run(_serve(output_fd)) + asyncio.run(_serve_stdio(output_fd)) if __name__ == "__main__": diff --git a/pyhap/media/worker.py b/pyhap/media/worker.py index 945d5f9d..8313b185 100644 --- a/pyhap/media/worker.py +++ b/pyhap/media/worker.py @@ -15,6 +15,11 @@ :meth:`MediaWorker.start`). When it exits unexpectedly, every session and source of it ends with a reason, and the next use starts a new one. +With ``MediaWorker(in_thread=True)`` the same worker runs in a thread of the +caller's process instead, with its own event loop and the same protocol over +a socket pair. That costs no second interpreter and keeps one process, but +the media path shares the GIL with the application. + The worker talks over its stdin and stdout pipes in length-prefixed frames: a 4-byte JSON length, a 4-byte binary length, a JSON object and raw bytes. Nothing is pickled. The worker moves its own stdout aside @@ -36,9 +41,11 @@ import itertools import logging import os +import socket import struct import sys -from typing import Any +import threading +from typing import Any, cast import orjson @@ -57,6 +64,7 @@ # The loggers the worker forwards, each at the parent's effective level of # the logger named here. FORWARDED_LOGGERS = {"pyhap": "pyhap.media"} +THREAD_NAME = "pyhap-media-worker" class MediaWorkerError(MediaError): @@ -135,22 +143,119 @@ def _ended(self, reason: str) -> None: _call(self.on_closed, reason) +class _ProcessRunner: + """The worker as a child process, talking over its stdin and stdout.""" + + def __init__(self, process: asyncio.subprocess.Process) -> None: + # Both are set: the process is started with pipes for them. + self.reader = cast(asyncio.StreamReader, process.stdout) + self.writer = cast(asyncio.StreamWriter, process.stdin) + self._process = process + + @property + def running(self) -> bool: + return self._process.returncode is None + + @property + def pid(self) -> int | None: + return self._process.pid + + def kill(self) -> None: + with contextlib.suppress(ProcessLookupError): + self._process.kill() + + async def wait(self) -> int: + return await self._process.wait() + + +class _ThreadRunner: + """The worker as a thread with its own loop, talking over a socket pair.""" + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + done: asyncio.Future[int], + ) -> None: + self.reader = reader + self.writer = writer + self._done = done + + @property + def running(self) -> bool: + return not self._done.done() + + @property + def pid(self) -> int | None: + return None + + def kill(self) -> None: + # A thread cannot be killed: drop the connection, which ends a worker + # that still runs its loop, and count the worker as gone either way. + self.writer.close() + if not self._done.done(): + self._done.set_result(-1) + + async def wait(self) -> int: + return await asyncio.shield(self._done) + + +async def _start_thread() -> _ThreadRunner: + from . import _worker # noqa: PLC0415 - the worker module imports this one + + loop = asyncio.get_running_loop() + done: asyncio.Future[int] = loop.create_future() + parent, child = socket.socketpair() + + def finished(code: int) -> None: + if not done.done(): + done.set_result(code) + + def run() -> None: + code = 0 + try: + asyncio.run(_worker.serve_socket(child)) + except Exception: + logger.exception("Media worker thread failed") + code = 1 + finally: + child.close() + # The parent loop may be gone when the application shuts down. + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(finished, code) + + try: + reader, writer = await asyncio.open_connection(sock=parent) + except BaseException: + parent.close() + child.close() + raise + threading.Thread(target=run, name=THREAD_NAME, daemon=True).start() + return _ThreadRunner(reader, writer, done) + + class MediaWorker: - """A worker process running media sources.""" + """A worker process, or with ``in_thread`` a thread, running media sources.""" def __init__( self, *, + in_thread: bool = False, python: str | None = None, start_timeout: float = DEFAULT_START_TIMEOUT, request_timeout: float = DEFAULT_REQUEST_TIMEOUT, ) -> None: - """Configure the worker; it starts on first use.""" + """Configure the worker; it starts on first use. + + ``in_thread`` runs it in a thread of this process instead of a child + process; ``python`` then has no effect. + """ self.on_exit: Callable[[str], None] | None = None + self._in_thread = in_thread self._python = python or sys.executable self._start_timeout = start_timeout self._request_timeout = request_timeout - self._process: asyncio.subprocess.Process | None = None + self._runner: _ProcessRunner | _ThreadRunner | None = None self._reader: asyncio.Task[None] | None = None self._ready: asyncio.Future[None] | None = None self._lock = asyncio.Lock() @@ -162,15 +267,20 @@ def __init__( self._handles: dict[int, Any] = {} self._sources: dict[int, RemoteMediaSource] = {} + @property + def in_thread(self) -> bool: + """Whether the worker runs in a thread instead of a child process.""" + return self._in_thread + @property def running(self) -> bool: - """Whether the worker process is up.""" - return self._process is not None and self._process.returncode is None + """Whether the worker is up.""" + return self._runner is not None and self._runner.running @property def pid(self) -> int | None: - """The worker's process ID while it runs.""" - return self._process.pid if self.running and self._process else None + """The worker's process ID while it runs as a child process.""" + return self._runner.pid if self.running and self._runner else None async def start(self) -> int: """Start the worker unless it runs; return its generation.""" @@ -181,6 +291,28 @@ async def start(self) -> int: return self._generation async def _spawn(self) -> None: + runner = await (_start_thread() if self._in_thread else self._start_process()) + if self._handles or self._sources or self._pending: + # The previous worker exited and its reader did not get to it yet. + self._exited(self._generation, "media worker exited") + self._generation += 1 + self._stopping = False + self._runner = runner + self._ready = asyncio.get_running_loop().create_future() + self._reader = asyncio.get_running_loop().create_task( + self._read_loop(runner, self._generation) + ) + try: + await asyncio.wait_for(asyncio.shield(self._ready), self._start_timeout) + except BaseException: + await self._kill(runner) + raise + if runner.pid is None: + logger.debug("Media worker started in a thread") + else: + logger.debug("Media worker %s started", runner.pid) + + async def _start_process(self) -> _ProcessRunner: env = dict(os.environ) env["PYTHONPATH"] = os.pathsep.join( os.path.abspath(path) for path in sys.path if path @@ -199,29 +331,17 @@ async def _spawn(self) -> None: env=env, cwd=os.path.abspath(os.sep), ) - if self._handles or self._sources or self._pending: - # The previous process exited and its reader did not get to it yet. - self._exited(self._generation, "media worker exited") - self._generation += 1 - self._stopping = False - self._process = process - self._ready = asyncio.get_running_loop().create_future() - self._reader = asyncio.get_running_loop().create_task( - self._read_loop(process, self._generation) - ) - try: - await asyncio.wait_for(asyncio.shield(self._ready), self._start_timeout) - except BaseException: - await self._kill(process) - raise - logger.debug("Media worker %s started", process.pid) + return _ProcessRunner(process) async def update_log_levels(self) -> None: """Apply the parent's current log levels in the worker. The worker starts with the levels of that moment; call this after - changing them, e.g. when an integration turns on debug logging. + changing them, e.g. when an integration turns on debug logging. A + worker thread shares the loggers, so there is nothing to apply. """ + if self._in_thread: + return await self._request("log_levels", self._generation, levels=_log_levels()) async def open_source( @@ -245,37 +365,30 @@ async def open_source( async def close(self) -> None: """End every session and stop the worker.""" async with self._lock: - process = self._process - if process is None or process.returncode is not None: + runner = self._runner + if runner is None or not runner.running: return self._stopping = True with contextlib.suppress(MediaWorkerError, asyncio.TimeoutError): await asyncio.wait_for( self._request("shutdown", self._generation), STOP_TIMEOUT ) - if process.stdin is not None: - process.stdin.close() + runner.writer.close() try: - await asyncio.wait_for(process.wait(), STOP_TIMEOUT) + await asyncio.wait_for(runner.wait(), STOP_TIMEOUT) except TimeoutError: - await self._kill(process) + await self._kill(runner) if self._reader is not None: with contextlib.suppress(asyncio.CancelledError, Exception): await self._reader - async def _kill(self, process: asyncio.subprocess.Process) -> None: - with contextlib.suppress(ProcessLookupError): - process.kill() - await process.wait() + async def _kill(self, runner: _ProcessRunner | _ThreadRunner) -> None: + runner.kill() + await runner.wait() async def _request(self, op: str, generation: int, **fields: Any) -> Any: - process = self._process - if ( - generation != self._generation - or process is None - or process.stdin is None - or not self.running - ): + runner = self._runner + if generation != self._generation or runner is None or not runner.running: raise MediaWorkerError("Media worker is not running") request_id = next(self._ids) try: @@ -286,7 +399,7 @@ async def _request(self, op: str, generation: int, **fields: Any) -> Any: self._pending[request_id] = future try: return await asyncio.wait_for( - self._exchange(process.stdin, data, future), self._request_timeout + self._exchange(runner.writer, data, future), self._request_timeout ) except (ConnectionError, BrokenPipeError) as err: raise MediaWorkerError("Media worker is not running") from err @@ -294,40 +407,37 @@ async def _request(self, op: str, generation: int, **fields: Any) -> Any: # A worker that stops answering is stuck: end it, so its sessions # end and the next use starts a new one. logger.warning("Media worker did not answer %s, stopping it", op) - with contextlib.suppress(ProcessLookupError): - process.kill() + runner.kill() raise MediaWorkerError(f"Media worker did not answer {op}") from err finally: self._pending.pop(request_id, None) @staticmethod async def _exchange( - stdin: asyncio.StreamWriter, data: bytes, future: asyncio.Future[Any] + writer: asyncio.StreamWriter, data: bytes, future: asyncio.Future[Any] ) -> Any: """Send a request and wait for its answer, under one deadline.""" - stdin.write(data) - await stdin.drain() + writer.write(data) + await writer.drain() return await future async def _read_loop( - self, process: asyncio.subprocess.Process, generation: int + self, runner: _ProcessRunner | _ThreadRunner, generation: int ) -> None: - stdout = process.stdout reason = "media worker exited" try: - while stdout is not None: - message, blob = await read_message(stdout) + while True: + message, blob = await read_message(runner.reader) self._dispatch(message, blob) - except asyncio.IncompleteReadError: + except (asyncio.IncompleteReadError, ConnectionError): pass except asyncio.CancelledError: raise except Exception as err: # noqa: BLE001 - a broken stream ends the worker logger.warning("Media worker sent invalid data: %r", err) reason = "media worker protocol error" - with contextlib.suppress(ProcessLookupError): - process.kill() - code = await process.wait() + runner.kill() + code = await runner.wait() if self._stopping: reason = "media worker stopped" self._exited(generation, f"{reason} (code {code})") diff --git a/tests/test_media_worker.py b/tests/test_media_worker.py index 7c076c1a..e613403f 100644 --- a/tests/test_media_worker.py +++ b/tests/test_media_worker.py @@ -76,8 +76,8 @@ async def test_lifecycle(worker: MediaWorker) -> None: def kill(worker: MediaWorker) -> None: """Crash the worker process.""" - assert worker._process is not None - worker._process.kill() + assert worker._runner is not None + worker._runner.kill() class Output: diff --git a/tests/test_media_worker_thread.py b/tests/test_media_worker_thread.py new file mode 100644 index 00000000..e627ad14 --- /dev/null +++ b/tests/test_media_worker_thread.py @@ -0,0 +1,136 @@ +"""Tests for the thread mode of pyhap.media.worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +import logging +import socket +import threading + +import pytest + +from pyhap.media import _worker +from pyhap.media.worker import THREAD_NAME, MediaWorker, MediaWorkerError + +pytestmark = pytest.mark.timeout(30) + + +@pytest.fixture +async def worker() -> AsyncIterator[MediaWorker]: + """Yield a started worker thread and stop it afterwards.""" + started = MediaWorker(in_thread=True) + await started.start() + yield started + await started.close() + + +def worker_threads() -> list[threading.Thread]: + """The worker threads that still run.""" + return [t for t in threading.enumerate() if t.name == THREAD_NAME] + + +async def wait_until_gone() -> None: + """Wait until no worker thread runs.""" + for _ in range(200): + if not worker_threads(): + return + await asyncio.sleep(0.01) + pytest.fail("worker thread still runs") + + +async def test_lifecycle(worker: MediaWorker) -> None: + """Verify the thread starts once, stops on close and starts again on use.""" + assert worker.in_thread + assert worker.running + assert worker.pid is None + assert len(worker_threads()) == 1 + generation = await worker.start() + assert len(worker_threads()) == 1 + + await worker.close() + assert not worker.running + await wait_until_gone() + await worker.close() + + assert await worker.start() == generation + 1 + assert worker.running + + +async def test_source_round_trip(worker: MediaWorker) -> None: + """Verify a source opens and closes in the thread like in a process.""" + source = await worker.open_source(audio=False) + host, video_port, audio_port = source.rtp_input + assert host == "127.0.0.1" + assert video_port > 0 + assert audio_port == 0 + assert not source.has_audio + await source.close() + assert source.closed + + +async def test_close_ends_sources() -> None: + """Verify closing the worker ends its sources with a reason.""" + worker = MediaWorker(in_thread=True) + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + exits: list[str] = [] + worker.on_exit = exits.append + + await worker.close() + + assert source.closed + assert reasons == [exits[0]] + assert "stopped" in exits[0] + await wait_until_gone() + + +async def test_kill_ends_sources_and_restarts(worker: MediaWorker) -> None: + """Verify a dropped thread ends its sources and the next use starts anew.""" + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + assert worker._runner is not None + + worker._runner.kill() + for _ in range(200): + if reasons: + break + await asyncio.sleep(0.01) + + assert source.closed + assert len(reasons) == 1 + again = await worker.open_source() + assert not again.closed + assert worker.running + + +async def test_loggers_are_shared(worker: MediaWorker) -> None: + """Verify the thread neither forwards records nor touches logger levels.""" + media = logging.getLogger("pyhap.media") + handlers = list(logging.getLogger("pyhap").handlers) + level = media.level + propagate = logging.getLogger("pyhap").propagate + + await worker.update_log_levels() + await worker.open_source() + + assert logging.getLogger("pyhap").handlers == handlers + assert media.level == level + assert logging.getLogger("pyhap").propagate == propagate + + +async def test_failed_thread_fails_start(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify a worker thread that dies before it is ready fails the start.""" + + async def broken(sock: socket.socket) -> None: + sock.close() + raise RuntimeError("boom") + + monkeypatch.setattr(_worker, "serve_socket", broken) + worker = MediaWorker(in_thread=True, start_timeout=5) + + with pytest.raises(MediaWorkerError): + await worker.start() + assert not worker.running