diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 3d1cb796..c093391f 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 @@ -1123,6 +1124,30 @@ def derive_session_key( return None return connection.derive_key(salt, info) + 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) 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 + 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], diff --git a/pyhap/camera.py b/pyhap/camera.py index 8de494d0..9c7e42d6 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -17,7 +17,7 @@ """ import asyncio -from collections.abc import AsyncGenerator, Callable, Iterable, Sequence +from collections.abc import AsyncGenerator, Callable, Iterable, Mapping, Sequence import contextlib import functools import hashlib @@ -26,7 +26,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 ( @@ -45,6 +46,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: @@ -625,6 +630,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`` """ @@ -689,11 +711,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. @@ -718,8 +749,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 @@ -755,6 +789,142 @@ 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() + + 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 + ``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. + + 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[ @@ -1160,6 +1330,7 @@ def set_manually_disabled(self, disabled: bool) -> None: if char is not None: char.set_value(bool(disabled)) self._end_streams_if_not_allowed() + self._update_stream_status() def set_third_party_camera_active(self, active: bool) -> None: """Report whether the camera is on for apps outside HomeKit. @@ -1176,6 +1347,7 @@ def _global_operating_mode_char(self, name: str) -> Characteristic | None: def _handle_global_operating_mode(self, name: str, value: Any) -> None: if name == "StreamingEnabled": self._end_streams_if_not_allowed() + self._schedule_stream_status() self.global_operating_mode_changed(name, value) def _end_streams_if_not_allowed(self) -> None: @@ -1316,6 +1488,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) @@ -1398,6 +1572,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 @@ -1624,6 +1799,7 @@ def _handle_homekit_camera_active(self, value: Any) -> None: if not active and self._recording_stream is not None: self._recording_stream.close(hds_recording.RecordingReason.NOT_ALLOWED) self._end_streams_if_not_allowed() + self._schedule_stream_status() self.homekit_camera_active_changed(active) def homekit_camera_active_changed(self, active: bool) -> None: @@ -1931,9 +2107,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..c9f1f8e1 --- /dev/null +++ b/pyhap/camera_multi_tier.py @@ -0,0 +1,854 @@ +"""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 + +import asyncio +from collections.abc import Mapping, Sequence +import contextlib +from dataclasses import dataclass, field +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, + SessionState, + StreamController, + StreamSession, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, +) +from pyhap.util import base64_to_bytes, to_base64_str + +if TYPE_CHECKING: + from pyhap.camera import Camera + +__all__ = [ + "DEFAULT_IDLE_TIMEOUT", + "FFMPEG_AUDIO_OUTPUT", + "FFMPEG_VIDEO_OUTPUT", + "MULTI_TIER_MIN_SESSIONS", + "FFmpegMultiTierDelegate", + "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) +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 + + 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. + + 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: + """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. 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 + 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 + setup_timeout: float = 30.0 + idle_timeout: float | None = DEFAULT_IDLE_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) + 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: + """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") + 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: + """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") + + 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. + return cast("MultiTierDelegate", self.delegate) + + 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: tuple[str, int] | None = 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: tuple[str, int] | None, + deadline: float, + ) -> MultiTierSession | hksv3.SetupEndpointsStatus: + session_id = request.session_id + endpoint = request.address + error = hksv3.SetupEndpointsStatus.ERROR + try: + address = str( + 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: tuple[str, int] | None = 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: tuple[str, int] | None, + 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 + 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: + 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) + 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, + ) + + +# 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 +# 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``, :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. + + 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_GLOBAL_OPTIONS, *ffmpeg_input) + self.audio = audio + + async def handle_prepare(self, session: MultiTierSession) -> None: + """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, + 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() + watcher = session.data.pop("watcher") + with contextlib.suppress(Exception): + 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( + "" 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)) + 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 and ffmpeg from + # blocking, so it keeps reading until ffmpeg closes it. + if process.stderr is None: + return + 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/pyhap/camera_stream.py b/pyhap/camera_stream.py new file mode 100644 index 00000000..73ce1bad --- /dev/null +++ b/pyhap/camera_stream.py @@ -0,0 +1,436 @@ +"""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: 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" + 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: 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.debug("[%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/pyhap/camera_webrtc.py b/pyhap/camera_webrtc.py new file mode 100644 index 00000000..774ad4a4 --- /dev/null +++ b/pyhap/camera_webrtc.py @@ -0,0 +1,484 @@ +"""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 Callable, Mapping +from dataclasses import dataclass +import logging +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, + SessionState, + StreamController, + StreamSession, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, +) +from pyhap.util import base64_to_bytes, to_base64_str + +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 + +_R = TypeVar("_R") +_STATUS = hksv3.WebRTCStreamingStatus + + +@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. + + 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( + 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. 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. + """ + + delegate: WebRTCDelegate | None = None + 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 + 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.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}") + + +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)) + + +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.""" + + 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) + 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), + ("WebRTCReoffer", self._set_reoffer), + ("WebRTCUpdateSession", self._set_update_session), + ): + service.configure_char(name, setter_callback=setter) + + 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") + + 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. + return cast("WebRTCDelegate", self.delegate) + + 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: tuple[str, int] | None = 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: tuple[str, int] | None, + 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: tuple[str, int] | None = 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: tuple[str, int] | None = 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) + + def _active_session( + 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) + 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: tuple[str, int] | None = 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: tuple[str, int] | None, + 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) + ) + 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: tuple[str, int] | None = 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/pyhap/hksv3.py b/pyhap/hksv3.py index 263eb317..713eb715 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 @@ -12,9 +15,10 @@ from __future__ import annotations -from collections.abc import Iterable +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 @@ -26,6 +30,7 @@ ZONES_VERSION = 2 _E = TypeVar("_E", bound=IntEnum) +_T = TypeVar("_T") class SensorType(IntEnum): @@ -277,3 +282,826 @@ 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( + UUID(bytes=_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( + UUID(bytes=_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, lambda data: UUID(bytes=data)), + _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 + + +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 +# 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: + # 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) + 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: + 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) + 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: + """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, lambda data: UUID(bytes=data)), + _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( + UUID(bytes=_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, lambda data: UUID(bytes=data)), + _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( + UUID(bytes=_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( + UUID(bytes=_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, lambda data: UUID(bytes=data)), + _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( + UUID(bytes=_required(fields, 1)), + [SFrameKey.decode(key) for key in keys], + [_to_int(_required(_fields(kid), 1)) for kid in kids], + ) diff --git a/tests/conftest.py b/tests/conftest.py index 7ad7edb1..35cc46e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,20 @@ """Test fictures and mocks.""" import asyncio -from collections.abc import Callable, Iterator -from typing import Any +from collections.abc import AsyncIterator, Callable, Iterator +import pathlib +from typing import Any, cast 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 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 @@ -60,3 +67,183 @@ 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), + ], +) +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 +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 + + +@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 + + +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.""" + + def __init__(self, *lines: bytes) -> None: + self.pid = 42 + self.returncode: int | None = None + self._exited = asyncio.Event() + 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)) + + 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_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.""" + 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 new file mode 100644 index 00000000..098d1d2c --- /dev/null +++ b/tests/test_camera_multi_tier.py @@ -0,0 +1,1262 @@ +"""Tests for the HKSV 17.99 multi-tier RTP streaming of pyhap.camera.""" + +import asyncio +from collections.abc import Callable +import logging +import struct +from typing import Any, cast +from unittest.mock import ANY, AsyncMock, MagicMock, patch +from uuid import UUID + +import pytest + +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, + RtcpMonitor, + _redact_command, + _redact_urls, + 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 + +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 _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() + 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) + + +# 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_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: + """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 + + +# 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[: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" + 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" + f"&localrtpport={session.accessory_audio_port}&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_status_lines_keep_the_pipe_drained( + stream_camera: Callable[..., Camera], + ffmpeg_process_factory: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """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 + 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: + """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 diff --git a/tests/test_camera_stream.py b/tests/test_camera_stream.py new file mode 100644 index 00000000..9fe9bb31 --- /dev/null +++ b/tests/test_camera_stream.py @@ -0,0 +1,483 @@ +"""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 +import logging +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, + SessionState, + StreamController, + StreamError, + StreamSession, + _check_hook_timeout, + _config_from_option, + _failure_status, + _HookFailed, +) + +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) + 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 + 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" diff --git a/tests/test_camera_webrtc.py b/tests/test_camera_webrtc.py new file mode 100644 index 00000000..90fb3f49 --- /dev/null +++ b/tests/test_camera_webrtc.py @@ -0,0 +1,683 @@ +"""Tests for the HKSV 17.99 WebRTC streaming of pyhap.camera.""" + +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 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") +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 _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() + + +# 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_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: + """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() 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() diff --git a/tests/test_hksv3.py b/tests/test_hksv3.py index 008bf588..83e3a4f7 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 @@ -7,13 +8,44 @@ from pyhap import hksv3 from pyhap.hksv3 import ( + AudioBitDepth, + AudioCodec, + AudioSampleRate, + AudioStreamTier, CameraCapabilities, CameraZones, + EndpointAddress, + IceCandidate, + RTPStreamingCommand, + RTPStreamingControl, + RTPStreamingStatus, SensorConfiguration, SensorIntent, SensorType, + SetupEndpoints, + SetupEndpointsResponse, + SetupEndpointsStatus, + SFrameKey, + SRTPCryptoSuite, + SRTPParameters, + SupportedAudioStreamTiers, + SupportedVideoStreamTiers, + VideoCodec, VideoQuality, VideoStreamCapability, + VideoStreamTier, + WebRTCOfferOptions, + WebRTCProvideAnswer, + WebRTCReoffer, + WebRTCReofferResponse, + WebRTCSessionStatus, + WebRTCSolicitOffer, + WebRTCSolicitOfferResponse, + WebRTCSolicitOfferStatus, + WebRTCStreamingCommand, + WebRTCStreamingControl, + WebRTCStreamingStatus, + WebRTCUpdateSession, ZoneData, ZoneMethod, ZonePolygon, @@ -369,3 +401,605 @@ 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)) + + +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)) + + +@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() + 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())