diff --git a/pyhap/resources/characteristics.json b/pyhap/resources/characteristics.json index 596e5898..639b04ff 100644 --- a/pyhap/resources/characteristics.json +++ b/pyhap/resources/characteristics.json @@ -133,15 +133,114 @@ "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": [ "pr", "pw", - "ev" + "ev", + "tw" ], "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 +364,14 @@ "ContactNotDetected": 1 } }, + "ContributingSensors": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008086-0000-1000-8000-0026BB765291" + }, "CoolingThresholdTemperature": { "Format": "float", "Permissions": [ @@ -825,6 +932,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": [ @@ -846,7 +966,8 @@ "Permissions": [ "pr", "pw", - "ev" + "ev", + "tw" ], "UUID": "0000011B-0000-1000-8000-0026BB765291" }, @@ -1055,6 +1176,15 @@ "SinglePress": 0 } }, + "RTPStreamingControl": { + "Format": "tlv8", + "Permissions": [ + "pr", + "pw", + "wr" + ], + "UUID": "00008045-0000-1000-8000-0026BB765291" + }, "RecordingAudioActive": { "Format": "uint8", "Permissions": [ @@ -1237,6 +1367,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 +1523,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 +1570,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 +1622,14 @@ ], "UUID": "00000144-0000-1000-8000-0026BB765291" }, + "SupportedVideoStreamTiers": { + "Format": "tlv8", + "Permissions": [ + "pr", + "ev" + ], + "UUID": "00008043-0000-1000-8000-0026BB765291" + }, "SwingMode": { "Format": "uint8", "Permissions": [ @@ -1708,7 +1874,6 @@ "Format": "bool", "Permissions": [ "pr", - "pw", "ev" ], "UUID": "0000021C-0000-1000-8000-0026BB765291" @@ -1804,6 +1969,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..a38f1426 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,174 @@ 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"] + + +@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