diff --git a/README.md b/README.md index 1400a9b0..625fb2ee 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ This will install HAP-python in your Python packages, so that you can import it `pyhap`. The following optional features can be installed via pip extras: * `QRCode` — print a pairing QR code in the terminal (`base36`, `pyqrcode`). -* `Talkback` — two-way audio for the camera accessory (`pylibsrtp`). * `bench` — dependencies for running the benchmarks (`psutil`, `py-spy`). ## Demo diff --git a/pyhap/camera.py b/pyhap/camera.py index ae9fa8b0..6faaf9ee 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -39,7 +39,7 @@ tlv, ) from pyhap.accessory import Accessory -from pyhap.camera_talkback import TalkbackReceiver, talkback_available +from pyhap.camera_talkback import TalkbackReceiver from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_CAMERA from pyhap.service import Service @@ -596,8 +596,7 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: - srtp - boolean, defaults to False. Whether the camera supports SRTP. - talkback - boolean, defaults to False. Whether the camera supports receiving two-way ("talkback") audio from the controller during a - live view session. Requires the optional ``pylibsrtp`` dependency - (``pip install HAP-python[Talkback]``). When enabled, a "Speaker" + live view session. When enabled, a "Speaker" service is added so that HAP clients know the accessory supports it, and :meth:`talkback_audio_received` is called with the received audio for each session that negotiates it. @@ -646,11 +645,6 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None: """ self.has_srtp = options.get("srtp", False) self.support_talkback = options.get("talkback", False) - if self.support_talkback and not talkback_available(): - raise ValueError( - "The 'talkback' option requires the optional 'pylibsrtp' " - "dependency. Install it with: pip install HAP-python[Talkback]" - ) self.start_stream_cmd = options.get("start_stream_cmd", FFMPEG_CMD) self.stream_address = options["address"] diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index 2cc21dde..b54cde9d 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -642,6 +642,10 @@ def _answer(session: MultiTierSession) -> hksv3.SetupEndpointsResponse: class FFmpegMultiTierDelegate: """Streams each session with its own ffmpeg process. + A simple reference: each session opens its own camera connection. + :class:`pyhap.media.MultiTierMediaDelegate` shares one pull per camera + channel among all local and remote sessions. + 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` diff --git a/pyhap/camera_talkback.py b/pyhap/camera_talkback.py index fcfec43c..9ba3e4f7 100644 --- a/pyhap/camera_talkback.py +++ b/pyhap/camera_talkback.py @@ -31,10 +31,7 @@ import threading from typing import cast -try: - import pylibsrtp -except ImportError: # pragma: no cover - exercised via requires_talkback() - pylibsrtp = None +import pylibsrtp logger = logging.getLogger(__name__) @@ -42,8 +39,12 @@ def talkback_available() -> bool: - """Whether the optional dependency for talkback support is installed.""" - return pylibsrtp is not None + """Whether talkback is supported, always since pylibsrtp is a dependency. + + Deprecated: kept for callers of the optional dependency check, it will be + removed in a future release. + """ + return True @dataclass @@ -148,12 +149,6 @@ def __init__( is_ipv6: bool = False, controller_address: str | None = None, ) -> None: - if pylibsrtp is None: - raise RuntimeError( - "Talkback support requires the 'pylibsrtp' package. " - "Install it with: pip install HAP-python[Talkback]" - ) - policy = pylibsrtp.Policy( key=srtp_key_and_salt, ssrc_type=pylibsrtp.Policy.SSRC_ANY_INBOUND, diff --git a/pyhap/media/__init__.py b/pyhap/media/__init__.py new file mode 100644 index 00000000..80d49ace --- /dev/null +++ b/pyhap/media/__init__.py @@ -0,0 +1,78 @@ +"""Media of HomeKit cameras: multi-tier RTP live view. + +:class:`MultiTierSender` and :class:`MediaSource` import their transport +(``pylibsrtp``) on first use; :class:`MediaWorker` runs them in a worker +process. :class:`MultiTierMediaDelegate` runs them for the multi-tier +sessions of :class:`pyhap.camera.Camera` from a :class:`SourcePool`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +class MediaError(Exception): + """A media session cannot do what was asked.""" + + +if TYPE_CHECKING: + from .ffmpeg import FFmpegInput, FFmpegSourceProvider + from .multi_tier import MultiTierSender + from .multi_tier_delegate import MultiTierMediaDelegate + from .pool import ( + ChannelLease, + ChannelPull, + MediaSourceLike, + MultiTierMediaSender, + SourceLease, + SourcePool, + ) + from .source import MediaSource + from .worker import ( + MediaWorker, + RemoteMediaSource, + RemoteMultiTierSender, + ) + +__all__ = [ + "ChannelLease", + "ChannelPull", + "FFmpegInput", + "FFmpegSourceProvider", + "MediaSource", + "MediaSourceLike", + "MultiTierMediaDelegate", + "MultiTierMediaSender", + "MultiTierSender", + "RemoteMediaSource", + "RemoteMultiTierSender", + "SourceLease", + "SourcePool", + "MediaError", + "MediaWorker", +] + +_LAZY = { + "ChannelLease": "pool", + "ChannelPull": "pool", + "FFmpegInput": "ffmpeg", + "FFmpegSourceProvider": "ffmpeg", + "MediaSourceLike": "pool", + "MultiTierMediaDelegate": "multi_tier_delegate", + "MultiTierMediaSender": "pool", + "MultiTierSender": "multi_tier", + "SourceLease": "pool", + "SourcePool": "pool", + "MediaSource": "source", + "RemoteMediaSource": "worker", + "RemoteMultiTierSender": "worker", + "MediaWorker": "worker", +} + + +def __getattr__(name: str) -> Any: + if name in _LAZY: + from importlib import import_module # noqa: PLC0415 + + return getattr(import_module(f".{_LAZY[name]}", __name__), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/pyhap/media/_worker.py b/pyhap/media/_worker.py new file mode 100644 index 00000000..ab47b915 --- /dev/null +++ b/pyhap/media/_worker.py @@ -0,0 +1,271 @@ +"""The media worker process, see :mod:`pyhap.media.worker`. + +Started as ``python -m pyhap.media._worker ``, one level per +logger in ``FORWARDED_LOGGERS``. Requests arrive framed on stdin; responses +and events leave framed on the original stdout, which is moved aside first. +The process ends when stdin closes. In thread mode :func:`serve_socket` runs +the same server over a socket pair instead. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import socket +import sys +from typing import Any + +from . import MediaError +from .multi_tier import MultiTierSender +from .source import MediaSource +from .worker import FORWARDED_LOGGERS, encode_message, read_message + +logger = logging.getLogger(__name__) + +# Bytes waiting for the parent above which log records are dropped, and +# above which the worker gives up on a parent that stopped reading. +LOG_DROP_BUFFER = 1024 * 1024 +MAX_OUTPUT_BUFFER = 64 * 1024 * 1024 + + +class _LogForwarder(logging.Handler): + def __init__(self, server: _Server) -> None: + super().__init__() + self._server = server + + def emit(self, record: logging.LogRecord) -> None: + try: + message = self.format(record) + self._server.send( + { + "event": "log", + "level": record.levelno, + "name": record.name, + "message": message, + } + ) + except Exception: # noqa: BLE001 - logging must never raise + self.handleError(record) + + +class _Server: + def __init__(self, output: asyncio.WriteTransport) -> None: + self._output = output + self._sessions: dict[int, MultiTierSender] = {} + self._sources: dict[int, MediaSource] = {} + self._tasks: set[asyncio.Task[None]] = set() + self.stopping = asyncio.Event() + + def send(self, message: dict[str, Any], blob: bytes = b"") -> None: + output = self._output + if output.is_closing(): + return + buffered = output.get_write_buffer_size() + if message.get("event") == "log" and buffered > LOG_DROP_BUFFER: + # A parent that reads slowly loses log records, not media events. + return + if buffered > MAX_OUTPUT_BUFFER: + # The parent stopped reading; it sees the worker as gone. + output.close() + self.stopping.set() + return + output.write(encode_message(message, blob)) + + def handle(self, message: dict[str, Any]) -> None: + task = asyncio.get_running_loop().create_task(self._respond(message)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _respond(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + try: + handler = getattr(self, f"_op_{message['op']}") + result = await handler(message) + except ( + MediaError, + ValueError, + KeyError, + TypeError, + RuntimeError, + OSError, + ) as err: + self.send({"id": request_id, "error": str(err) or type(err).__name__}) + return + except Exception as err: + logger.exception("Media worker request failed") + self.send({"id": request_id, "error": repr(err)}) + return + try: + self.send({"id": request_id, "result": result}) + except (ValueError, TypeError) as err: + # Too large or not serializable: the caller still gets an answer. + self.send({"id": request_id, "error": f"Answer not sent: {err}"}) + + async def _op_open_source(self, message: dict[str, Any]) -> dict[str, Any]: + source_id = int(message["source"]) + source = MediaSource(str(message["host"]), audio=bool(message["audio"])) + source.on_closed = lambda reason: self._source_closed(source_id, reason) + self._sources[source_id] = source + try: + rtp_input = await source.start() + except BaseException: + self._sources.pop(source_id, None) + source.on_closed = None + source.close() + raise + return {"rtp_input": list(rtp_input)} + + def _source_closed(self, source_id: int, reason: str) -> None: + if self._sources.pop(source_id, None) is not None: + self.send({"event": "source_closed", "source": source_id, "reason": reason}) + + async def _op_close_source(self, message: dict[str, Any]) -> None: + source = self._sources.pop(int(message["source"]), None) + if source is not None: + source.close(str(message.get("reason", "closed"))) + + def _multi_tier(self, message: dict[str, Any]) -> MultiTierSender: + sender = self._sessions.get(int(message["session"])) + if not isinstance(sender, MultiTierSender): + raise MediaError("Unknown multi-tier session") + return sender + + def _source(self, message: dict[str, Any]) -> MediaSource: + source = self._sources.get(int(message["source"])) + if source is None: + raise MediaError("Unknown media source") + return source + + async def _op_mt_bind(self, message: dict[str, Any]) -> dict[str, Any]: + session_id = int(message["session"]) + config = dict(message["config"]) + config["video_key"] = bytes.fromhex(config["video_key"]) + config["audio_key"] = bytes.fromhex(config["audio_key"]) + sender = MultiTierSender(**config) + + def closed(reason: str) -> None: + self._sessions.pop(session_id, None) + self.send({"event": "closed", "session": session_id, "reason": reason}) + + sender.on_closed = closed + sender.on_rtcp = lambda: self.send({"event": "rtcp", "session": session_id}) + sender.on_keyframe_request = lambda age, gop: self.send( + {"event": "keyframe_request", "session": session_id, "age": age, "gop": gop} + ) + self._sessions[session_id] = sender + try: + ports = await sender.bind() + except BaseException: + self._sessions.pop(session_id, None) + await sender.close() + raise + return {"ports": list(ports)} + + async def _op_mt_start(self, message: dict[str, Any]) -> None: + await self._multi_tier(message).start( + self._source(message), audio=bool(message["audio"]) + ) + + async def _op_mt_switch(self, message: dict[str, Any]) -> None: + await self._multi_tier(message).switch(self._source(message)) + + async def _op_close(self, message: dict[str, Any]) -> None: + sender = self._sessions.pop(int(message["session"]), None) + if sender is not None: + await sender.close() + + async def _op_log_levels(self, message: dict[str, Any]) -> None: + for name, level in zip(FORWARDED_LOGGERS, message["levels"], strict=False): + logging.getLogger(name).setLevel(int(level)) + + async def _op_shutdown(self, message: dict[str, Any]) -> None: + await self.close_all() + self.stopping.set() + + async def close_all(self) -> None: + sessions, self._sessions = self._sessions, {} + await asyncio.gather( + *(sender.close() for sender in sessions.values()), return_exceptions=True + ) + sources, self._sources = self._sources, {} + for source in sources.values(): + source.on_closed = None + source.close("media worker stopped") + + +async def _serve( + reader: asyncio.StreamReader, + output: asyncio.WriteTransport, + *, + forward_logs: bool, +) -> None: + loop = asyncio.get_running_loop() + server = _Server(output) + forwarder = _LogForwarder(server) if forward_logs else None + if forwarder is not None: + for name in FORWARDED_LOGGERS: + logging.getLogger(name).addHandler(forwarder) + server.send({"event": "ready"}) + stopping = loop.create_task(server.stopping.wait()) + try: + while not server.stopping.is_set(): + reading = loop.create_task(read_message(reader)) + await asyncio.wait({reading, stopping}, return_when=asyncio.FIRST_COMPLETED) + if not reading.done(): + reading.cancel() + break + try: + message, _blob = reading.result() + except asyncio.IncompleteReadError: + break + server.handle(message) + finally: + stopping.cancel() + await server.close_all() + output.close() + if forwarder is not None: + for name in FORWARDED_LOGGERS: + logging.getLogger(name).removeHandler(forwarder) + + +async def _serve_stdio(output_fd: int) -> None: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(0, "rb", buffering=0) + ) + output, _ = await loop.connect_write_pipe( + asyncio.Protocol, os.fdopen(output_fd, "wb", buffering=0) + ) + await _serve(reader, output, forward_logs=True) + + +async def serve_socket(sock: socket.socket) -> None: + """Serve one parent over a connected socket, in the parent's process. + + The worker thread of :class:`~pyhap.media.worker.MediaWorker` runs this in + its own event loop. Loggers are shared with the parent, so nothing is + forwarded and their levels stay as the parent sets them. + """ + reader, writer = await asyncio.open_connection(sock=sock) + await _serve(reader, writer.transport, forward_logs=False) + + +def main() -> None: + """Run the worker until its stdin closes or it is told to stop.""" + # Keep the protocol stream for frames; anything printed goes to stderr. + output_fd = os.dup(1) + os.dup2(2, 1) + levels = [int(arg) for arg in sys.argv[1:]] + levels += [logging.WARNING] * (len(FORWARDED_LOGGERS) - len(levels)) + for name, level in zip(FORWARDED_LOGGERS, levels, strict=False): + logging.getLogger(name).setLevel(level) + logging.getLogger(name).propagate = False + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(_serve_stdio(output_fd)) + + +if __name__ == "__main__": + main() diff --git a/pyhap/media/ffmpeg.py b/pyhap/media/ffmpeg.py new file mode 100644 index 00000000..d72e62ef --- /dev/null +++ b/pyhap/media/ffmpeg.py @@ -0,0 +1,550 @@ +"""A reference source provider that pulls each camera channel with ffmpeg. + +:class:`FFmpegSourceProvider` is a ``source_provider`` for +:class:`~pyhap.media.multi_tier_delegate.MultiTierMediaDelegate`: it +resolves the tier of a session to ffmpeg input arguments and leases that +channel's source from its :class:`~pyhap.media.pool.SourcePool`, with an +:class:`FFmpegPull` as the channel's pull. All sessions of a channel share +one ffmpeg. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping, Sequence +import contextlib +from dataclasses import dataclass, replace +from functools import partial +import logging +import re +import time +from typing import TYPE_CHECKING, Any + +from pyhap import hksv3 +from pyhap.camera_stream import StreamError + +from .pool import ChannelLease, SourcePool + +if TYPE_CHECKING: + from pyhap.camera_stream import StreamSession + + from .worker import MediaWorker + +logger = logging.getLogger(__name__) + +_OFFER_ERROR = hksv3.WebRTCSolicitOfferStatus.ERROR +# Seconds ffmpeg gets to exit after SIGTERM before it is killed. +FFMPEG_STOP_TIMEOUT = 3.0 +# Userinfo may hold spaces and quotes, so it is matched up to its "@" first. +_URL = re.compile( + r"(?P[A-Za-z][A-Za-z0-9+.-]*://)(?:[^/?#@\n]*@)?(?P[^\s'\"]+)" +) +# Bytes of one stderr line kept before it is logged anyway. +MAX_LOG_LINE = 4096 +# Restarts after failures back off up to this many seconds. +MAX_RESTART_DELAY = 30.0 +# An ffmpeg that ran this long counts as healthy again. +HEALTHY_RUN = 30.0 + + +def redact_urls(text: str) -> str: + """Keep only scheme, host and port of the URLs in ``text``. + + Credentials, path and query of a camera URL may carry secrets, such as + an access token in the path. + """ + return _URL.sub(_redact_url, text) + + +def _redact_url(match: re.Match[str]) -> str: + rest = match["rest"] + authority = re.split(r"[/?#]", rest, maxsplit=1)[0] + host = authority.rpartition("@")[2] + hidden = "/" if len(authority) < len(rest) else "" + return f"{match['scheme']}{host}{hidden}" + + +@dataclass(frozen=True) +class FFmpegInput: + """The ffmpeg input of a camera channel. + + ``args`` are ffmpeg's input arguments, such as ``["-rtsp_transport", + "tcp", "-i", url]``. The other fields override the provider's defaults + when set: ``video_map`` and ``audio_map`` select the streams, ``audio`` + turns audio off for inputs without it, and ``gop`` is the expected + interval between IRAP pictures until the source has measured it. + """ + + args: tuple[str, ...] + video_map: str | None = None + audio_map: str | None = None + audio: bool | None = None + gop: float | None = None + + +InputResolver = Callable[ + [hksv3.VideoStreamTier], Awaitable["Sequence[str] | FFmpegInput | None"] +] + + +class FFmpegSourceProvider: + """A ``source_provider`` that pulls each channel with ffmpeg. + + ``inputs`` maps a tier identifier, or a :class:`pyhap.hksv3.VideoQuality` + (also by name, e.g. ``"medium"``), to ffmpeg input arguments or an + :class:`FFmpegInput`; an identifier wins over a quality. It can instead + be an async callable that resolves a tier to the same, or None when + nothing fits. Tiers with the same input share one ffmpeg. + + Video ``video_map`` is copied with ``dump_extra`` so every IRAP picture + carries its parameter sets (an Annex B input such as RTSP; MP4 needs + ``hevc_mp4toannexb``); ``audio_map``, unless ``audio`` is false, is + encoded to mono Opus. ffmpeg fails when a mapped stream is missing, so + set ``audio=False`` for inputs without audio. The Opus bit rate and frame + duration follow the audio tier of the session that opens the channel; a + camera has one audio tier for its multi-tier and WebRTC sessions, so all + sessions of a channel get the same audio. The command is an argument + list that no shell sees; the URLs in ffmpeg's log are reduced to scheme, + host and port. + + ffmpeg is restarted when it exits while sessions need it (after + ``restart_delay``), and for a keyframe request only when no IRAP picture + came for twice the GOP and ffmpeg runs at least that long: a copied + stream gets its next IRAP picture with the next GOP anyway. The GOP is + the one the source measures, until then ``gop``. + """ + + def __init__( + self, + inputs: Mapping[int | hksv3.VideoQuality | str, Sequence[str] | FFmpegInput] + | InputResolver, + *, + worker: MediaWorker | None = None, + ffmpeg: str = "ffmpeg", + video_map: str = "0:v:0", + audio_map: str = "0:a:0", + audio: bool = True, + gop: float = 2.0, + restart_delay: float = 1.0, + stats_period: float | None = None, + host: str = "127.0.0.1", + on_error: Callable[[str, str], None] | None = None, + ) -> None: + """Configure the inputs; nothing runs before the first answer. + + ``on_error(channel, message)`` hears each unexpected ffmpeg exit or + failed start, e.g. to mark the camera unavailable. + """ + self._resolver: InputResolver | None = None + self._by_id: dict[int, FFmpegInput] = {} + self._by_quality: dict[int, FFmpegInput] = {} + if not isinstance(inputs, Mapping): + self._resolver = inputs + else: + for key, value in inputs.items(): + if isinstance(key, str): + self._by_quality[hksv3.VideoQuality[key.upper()]] = _input(value) + elif isinstance(key, hksv3.VideoQuality): + self._by_quality[key] = _input(value) + else: + self._by_id[key] = _input(value) + self.pool = SourcePool(worker, host) + self.ffmpeg = ffmpeg + self.video_map = video_map + self.audio_map = audio_map + self.audio = audio + self.gop = gop + self.restart_delay = restart_delay + self.stats_period = stats_period + self.on_error = on_error + self._pulls: dict[str, FFmpegPull] = {} + + async def input_for(self, tier: hksv3.VideoStreamTier) -> FFmpegInput: + """The input of a tier, with the provider's defaults filled in. + + :raises StreamError: If no input fits the tier. + """ + if self._resolver is not None: + value = await self._resolver(tier) + found = None if value is None else _input(value) + else: + found = self._by_id.get(tier.identifier) or self._by_quality.get( + tier.quality + ) + if found is None: + raise StreamError(_OFFER_ERROR, f"No ffmpeg input for tier {tier}") + return FFmpegInput( + found.args, + found.video_map or self.video_map, + found.audio_map or self.audio_map, + self.audio if found.audio is None else found.audio, + found.gop or self.gop, + ) + + def channel_name(self, tier: hksv3.VideoStreamTier) -> str: + """How the log names the channel of a tier, without its input.""" + if tier.identifier in self._by_id: + return f"tier {tier.identifier}" + with contextlib.suppress(ValueError): + return hksv3.VideoQuality(tier.quality).name.lower() + return f"tier {tier.identifier}" + + def command( + self, + ffmpeg_input: FFmpegInput, + rtp_input: tuple[str, int, int], + audio_bitrate: int, + packet_time: int = 20, + ) -> list[str]: + """The ffmpeg arguments sending one input to a source's ports.""" + host, video_port, audio_port = rtp_input + host = f"[{host}]" if ":" in host else host + stats = ( + ["-stats", "-stats_period", str(self.stats_period)] + if self.stats_period + else ["-nostats"] + ) + video_map = ffmpeg_input.video_map or self.video_map + command = [ + *(self.ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "warning"), + *stats, + *ffmpeg_input.args, + *("-map", video_map, "-c:v", "copy", "-bsf:v", "dump_extra"), + *("-payload_type", str(hksv3.VIDEO_PAYLOAD_TYPE)), + # The source drops RTCP, and port + 1 may be the audio input. + *("-rtpflags", "skip_rtcp", "-f", "rtp", f"rtp://{host}:{video_port}"), + ] + if audio_port: + audio_map = ffmpeg_input.audio_map or self.audio_map + command += [ + *("-map", audio_map, "-c:a", "libopus", "-ac", "1", "-ar", "48000"), + *("-b:a", str(audio_bitrate), "-application", "lowdelay"), + *("-frame_duration", str(packet_time)), + *("-payload_type", str(hksv3.AUDIO_PAYLOAD_TYPE)), + *("-rtpflags", "skip_rtcp", "-f", "rtp", f"rtp://{host}:{audio_port}"), + ] + return command + + async def __call__( + self, + session: StreamSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> ChannelLease: + """Lease the source of the session's channel. + + ``audio_tier`` is None for a multi-tier session without audio; the + channel's audio then follows :data:`pyhap.hksv3.DEFAULT_AUDIO_TIER`. + """ + return await self.lease_channel(video_tier, audio_tier) + + async def lease_channel( + self, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None = None, + ) -> ChannelLease: + """Lease the source of a tier's channel without a session. + + For example to keep a channel buffered between sessions. + """ + found = await self.input_for(video_tier) + name = self.channel_name(video_tier) + audio = audio_tier or hksv3.DEFAULT_AUDIO_TIER + command = partial( + self.command, + found, + audio_bitrate=audio.target_bitrate, + packet_time=audio.packet_time, + ) + gop = found.gop or self.gop + + def make_pull() -> FFmpegPull: + pull = FFmpegPull( + command, name, gop, self.restart_delay, on_error=self._pull_error + ) + self._pulls[name] = pull + return pull + + return await self.pool.lease( + replace(found, gop=None), + make_pull, + audio=bool(found.audio), + name=name, + ) + + def _pull_error(self, channel: str, message: str) -> None: + if self.on_error is not None: + try: + self.on_error(channel, message) + except Exception: + logger.exception("Error in the ffmpeg error callback") + + def diagnostics(self) -> dict[str, Any]: + """Leases and the ffmpeg of each channel, for an integration's diagnostics.""" + counts = self.pool.counts() + worker = self.pool.worker + return { + "worker_pid": worker.pid if worker is not None else None, + "channels": { + name: { + "leases": leases, + "active": active, + **(self._pulls[name].diagnostics() if name in self._pulls else {}), + } + for name, (leases, active) in counts.items() + }, + } + + async def close(self) -> None: + """Stop every ffmpeg and close every source.""" + await self.pool.close() + + +def _input(value: Sequence[str] | FFmpegInput) -> FFmpegInput: + if isinstance(value, FFmpegInput): + return replace(value, args=tuple(value.args)) + return FFmpegInput(tuple(value)) + + +class FFmpegPull: + """One ffmpeg process feeding a channel's source, see ``ChannelPull``.""" + + def __init__( + self, + command: Callable[[tuple[str, int, int]], list[str]], + name: str, + gop: float, + restart_delay: float, + on_error: Callable[[str, str], None] | None = None, + ) -> None: + """Configure the pull; :meth:`start` runs ffmpeg. + + ``gop`` is the expected IRAP interval until the source measured one. + ``on_error(name, message)`` hears each unexpected exit or failed start. + """ + self.name = name + self.gop = gop + self.restart_delay = restart_delay + self.on_error = on_error + self.restarts = 0 + self.last_error: str | None = None + self._failures = 0 + self._command = command + self._argv: list[str] = [] + self._process: asyncio.subprocess.Process | None = None + self._watcher: asyncio.Task[None] | None = None + self._started = 0.0 + self._running = False + self._restart_now = asyncio.Event() + + @property + def pid(self) -> int | None: + """The process ID of the running ffmpeg.""" + process = self._process + return process.pid if process and process.returncode is None else None + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Run ffmpeg and keep it running until :meth:`stop`.""" + self._argv = self._command(rtp_input) + self._running = True + try: + await self._spawn() + except BaseException as err: + self._running = False + if isinstance(err, OSError): + message = f"could not start: {err}" + logger.warning("ffmpeg for %s %s", self.name, message) + self._report(message) + raise + self._watcher = asyncio.ensure_future(self._watch()) + + async def _spawn(self) -> None: + self._restart_now.clear() + self._process = await asyncio.create_subprocess_exec( + *self._argv, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + self._started = time.monotonic() + logger.info("ffmpeg for %s started, pid %d", self.name, self._process.pid) + + def diagnostics(self) -> dict[str, Any]: + """The pid, restarts and last error, for diagnostics.""" + return { + "pid": self.pid, + "restarts": self.restarts, + "last_error": self.last_error, + } + + async def _watch(self) -> None: + while self._running and self._process is not None: + process = self._process + last_line = await _log_output(self.name, process) + code = await process.wait() + if not self._running: + return + if not self._restart_now.is_set(): + if time.monotonic() - self._started >= HEALTHY_RUN: + self._failures = 0 + await self._back_off( + f"exited with {code}" + (f": {last_line}" if last_line else "") + ) + while self._running: + try: + await self._spawn() + except OSError as err: + # No new process: back off further before the next try. + await self._back_off(f"could not start: {err}") + continue + self.restarts += 1 + break + + async def _back_off(self, message: str) -> None: + """Report ``message`` and wait the next backoff delay or a restart.""" + delay = min(self.restart_delay * 2**self._failures, MAX_RESTART_DELAY) + self._failures += 1 + self._error(message, delay) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._restart_now.wait(), delay) + + def _error(self, message: str, delay: float) -> None: + logger.warning( + "ffmpeg for %s %s, restarting in %.1f s", self.name, message, delay + ) + self._report(message) + + def _report(self, message: str) -> None: + self.last_error = message + if self.on_error is not None: + try: + self.on_error(self.name, message) + except Exception: + logger.exception("Error in the ffmpeg error callback") + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Restart ffmpeg if its stream has gone without IRAP pictures. + + ``gop`` is the IRAP interval the source measured, if it did. + """ + limit = 2 * (gop or self.gop) + if not self._running or self._restart_now.is_set(): + return + if age is not None and age <= limit: + return + running = time.monotonic() - self._started + if running < limit: + # Started recently, its first IRAP picture is on the way. + return + logger.info( + "Restarting ffmpeg for %s for an IRAP picture (last %s s ago)", + self.name, + "-" if age is None else f"{age:.1f}", + ) + self._restart_now.set() + if self._process is not None and self._process.returncode is None: + self._process.terminate() + + async def stop(self) -> None: + """Stop ffmpeg, killing it if it does not exit in time.""" + self._running = False + self._restart_now.set() + await self._terminate() + if self._watcher is not None: + await self._watcher + # The watcher may have started one more meanwhile. + await self._terminate() + process = self._process + if process is not None: + logger.info( + "ffmpeg for %s stopped (exit %s, %d restarts)", + self.name, + process.returncode, + self.restarts, + ) + + async def _terminate(self) -> None: + process = self._process + if process is None or process.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), FFMPEG_STOP_TIMEOUT) + except TimeoutError: + logger.warning("Killing ffmpeg for %s", self.name) + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.wait() + + +async def _log_output(name: str, process: asyncio.subprocess.Process) -> str | None: + """Log ffmpeg's output line by line, URLs redacted; progress ends with CR. + + Returns the last line, redacted, to explain an exit. + """ + stream = process.stderr + if stream is None: + return None + lines = StderrLines() + last: bytes | None = None + while chunk := await stream.read(4096): + for line in lines.feed(chunk): + _log_line(name, line) + last = line + for line in lines.end(): + _log_line(name, line) + last = line + if last is None: + return None + return redact_urls(last.decode(errors="replace")).strip() or None + + +class StderrLines: + """Splits ffmpeg's stderr into lines of at most about MAX_LOG_LINE bytes. + + An overlong line is returned once, cut, and its rest skipped up to the + next line end. Logging it in parts could split a URL so that its + credentials escape redaction. + """ + + def __init__(self) -> None: + self.pending = b"" + self._skipping = False + + def feed(self, chunk: bytes) -> list[bytes]: + """Add output, return the lines it completes.""" + lines = (self.pending + chunk).replace(b"\r", b"\n").split(b"\n") + self.pending = lines.pop() + if self._skipping and lines: + lines.pop(0) + self._skipping = False + done = [_cut(line) for line in lines if line] + if self._skipping: + self.pending = b"" + elif len(self.pending) > MAX_LOG_LINE: + done.append(_cut(self.pending)) + self.pending = b"" + self._skipping = True + return done + + def end(self) -> list[bytes]: + """Return the last line, which had no line end.""" + return [self.pending] if self.pending else [] + + +def _cut(line: bytes) -> bytes: + """Cut an overlong line before its last URL and its last partial word. + + A URL may hold spaces in its userinfo, so a cut URL is dropped whole. + """ + if len(line) <= MAX_LOG_LINE: + return line + head = line[:MAX_LOG_LINE] + if (url := head.rfind(b"://")) != -1: + head = head[:url] + return head.rpartition(b" ")[0] + b" [truncated]" + + +def _log_line(name: str, line: bytes) -> None: + logger.debug("ffmpeg %s: %s", name, redact_urls(line.decode(errors="replace"))) diff --git a/pyhap/media/hevc.py b/pyhap/media/hevc.py new file mode 100644 index 00000000..8bc029be --- /dev/null +++ b/pyhap/media/hevc.py @@ -0,0 +1,339 @@ +"""H.265 RTP payload handling (RFC 7798) for the camera media senders. + +The integrator's ffmpeg sends HEVC as RTP (``-c:v copy -bsf:v dump_extra -f +rtp``). That suits Annex B inputs such as RTSP; an MP4 or other hvcC input +needs ``-bsf:v hevc_mp4toannexb`` instead, which also puts the parameter sets +in front of every IRAP picture. SFrame protects whole frames, so the sender +turns that RTP back into access units, writes each one as 4-byte +length-prefixed NAL units (the hvcC sample layout) and encrypts the result. + +Only the RFC 7798 packet types without DONL are handled: single NAL unit +packets, aggregation packets (type 48) and fragmentation units (type 49). The +receiver needs VPS, SPS and PPS in-band in front of every IRAP picture, since +the SDP carries no sprop parameter sets; parameter sets seen earlier are +inserted when an IRAP access unit arrives without them. + +The SFrame plaintext matches what Scrypted's HomeKit WebRTC plugin sends for +the same RTP input (https://github.com/koush/scrypted, ``plugins/webrtc``). + +:class:`HevcPacketizer` is the reverse direction, for sessions without +SFrame, which send plain RFC 7798 RTP. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +import logging +import struct + +logger = logging.getLogger(__name__) + +NAL_HEADER_SIZE = 2 +NAL_VPS = 32 +NAL_SPS = 33 +NAL_PPS = 34 +NAL_AUD = 35 +NAL_AP = 48 +NAL_FU = 49 +IRAP_FIRST = 16 +IRAP_LAST = 23 +PARAMETER_SETS = (NAL_VPS, NAL_SPS, NAL_PPS) +MAX_ACCESS_UNIT = 16 * 1024 * 1024 + + +def nal_type(nal: bytes) -> int: + """Return the NAL unit type of an H.265 NAL unit or RTP payload.""" + return (nal[0] >> 1) & 0x3F + + +def is_irap(nal: bytes) -> bool: + """Whether a NAL unit is an IRAP (BLA, IDR or CRA) picture slice.""" + return IRAP_FIRST <= nal_type(nal) <= IRAP_LAST + + +def payload_starts_irap(payload: bytes) -> bool: + """Whether an RTP payload starts or carries an IRAP slice. + + A cheap look at the payload header (and the FU header or AP units), + without reassembling anything. + """ + if len(payload) < NAL_HEADER_SIZE + 1: + return False + kind = nal_type(payload) + if kind == NAL_FU: + return bool(payload[2] & 0x80) and IRAP_FIRST <= payload[2] & 0x3F <= IRAP_LAST + if kind == NAL_AP: + return any(is_irap(nal) for nal in split_aggregation_packet(payload) or ()) + return is_irap(payload) + + +def length_prefixed(nals: Iterable[bytes]) -> bytes: + """Join NAL units with 4-byte big-endian length prefixes.""" + return b"".join(struct.pack(">I", len(nal)) + nal for nal in nals) + + +def split_length_prefixed(data: bytes) -> list[bytes]: + """Split 4-byte length-prefixed NAL units.""" + nals = [] + offset = 0 + while offset < len(data): + if offset + 4 > len(data): + raise ValueError("Truncated NAL unit length") + (length,) = struct.unpack_from(">I", data, offset) + offset += 4 + if offset + length > len(data): + raise ValueError("Truncated NAL unit") + nals.append(data[offset : offset + length]) + offset += length + return nals + + +def split_aggregation_packet(payload: bytes) -> list[bytes] | None: + """Return the NAL units of an AP, or ``None`` when it is malformed.""" + nals = [] + offset = NAL_HEADER_SIZE + while offset < len(payload): + if offset + 2 > len(payload): + return None + (length,) = struct.unpack_from(">H", payload, offset) + offset += 2 + if length < NAL_HEADER_SIZE or offset + length > len(payload): + return None + nals.append(payload[offset : offset + length]) + offset += length + return nals or None + + +@dataclass +class AccessUnit: + """All NAL units of one picture, sharing one RTP timestamp.""" + + timestamp: int + nals: list[bytes] = field(default_factory=list) + + @property + def keyframe(self) -> bool: + """Whether the access unit contains an IRAP picture.""" + return any(is_irap(nal) for nal in self.nals) + + @property + def self_contained(self) -> bool: + """Whether a decoder can start here: IRAP with VPS, SPS and PPS.""" + kinds = {nal_type(nal) for nal in self.nals} + return self.keyframe and all(kind in kinds for kind in PARAMETER_SETS) + + def to_bytes(self) -> bytes: + """Return the 4-byte length-prefixed SFrame plaintext.""" + return length_prefixed(self.nals) + + +class HevcDepacketizer: + """Reassembles access units from RFC 7798 RTP payloads. + + An access unit ends with the RTP marker bit. A sequence gap, a broken + fragmentation unit or an unsupported packet type damages the access unit, + which is then dropped; the next one starts clean. + """ + + def __init__(self) -> None: + """Start without cached parameter sets.""" + self.parameter_sets: dict[int, bytes] = {} + self.dropped = 0 + self._timestamp: int | None = None + self._last_sequence: int | None = None + self._nals: list[bytes] = [] + self._size = 0 + self._fragment: list[bytes] | None = None + self._damaged = False + + def reset(self) -> None: + """Drop the access unit in progress, for example on a new input SSRC. + + Cached parameter sets go too: a new source sends its own in-band, and + an old set must not be put in front of its pictures. + """ + self.parameter_sets = {} + self.restart() + + def restart(self) -> None: + """Drop the access unit in progress and forget the last sequence. + + Cached parameter sets stay, for a pause in the same input stream. + """ + self._last_sequence = None + self._reset() + + def push( + self, sequence: int, timestamp: int, marker: bool, payload: bytes + ) -> AccessUnit | None: + """Add one RTP payload; return the access unit completed by it.""" + if self._timestamp is not None and timestamp != self._timestamp: + # A new timestamp without a marker: the previous unit is incomplete. + self._drop() + if self._timestamp is None: + self._timestamp = timestamp + if ( + self._last_sequence is not None + and sequence != (self._last_sequence + 1) & 0xFFFF + ): + self._damaged = True + self._last_sequence = sequence + if not self._damaged: + self._add_payload(payload) + if not marker: + return None + if self._fragment is not None: + self._damaged = True + if self._damaged or not self._nals: + self._drop() + return None + unit = AccessUnit(timestamp, self._complete(self._nals)) + self._reset() + return unit + + def _add_payload(self, payload: bytes) -> None: + if len(payload) < NAL_HEADER_SIZE: + self._damaged = True + return + kind = nal_type(payload) + if kind == NAL_FU: + self._add_fragment(payload) + return + if self._fragment is not None: + self._damaged = True + return + if kind == NAL_AP: + nals = split_aggregation_packet(payload) + if nals is None: + self._damaged = True + return + for nal in nals: + self._add_nal(nal) + return + if kind > NAL_AP: + self._damaged = True + return + self._add_nal(payload) + + def _add_fragment(self, payload: bytes) -> None: + if len(payload) < 3: + self._damaged = True + return + fu_header = payload[2] + start, end = fu_header & 0x80, fu_header & 0x40 + if start: + if self._fragment is not None: + self._damaged = True + return + header = bytes([(payload[0] & 0x81) | (fu_header & 0x3F) << 1, payload[1]]) + self._fragment = [header] + elif self._fragment is None: + self._damaged = True + return + self._fragment.append(payload[3:]) + self._size += len(payload) - 3 + if self._size > MAX_ACCESS_UNIT: + self._damaged = True + return + if end: + nal = b"".join(self._fragment) + self._fragment = None + self._size -= len(nal) - NAL_HEADER_SIZE + self._add_nal(nal) + + def _add_nal(self, nal: bytes) -> None: + self._size += 4 + len(nal) + if self._size > MAX_ACCESS_UNIT: + # Bound memory when a source never sets the marker bit. + self._damaged = True + return + kind = nal_type(nal) + if kind in PARAMETER_SETS: + self.parameter_sets[kind] = nal + self._nals.append(nal) + + def _complete(self, nals: list[bytes]) -> list[bytes]: + """Put cached parameter sets in front of an IRAP that lacks them.""" + if not any(is_irap(nal) for nal in nals): + return nals + present = {nal_type(nal) for nal in nals} + missing = [ + self.parameter_sets[kind] + for kind in PARAMETER_SETS + if kind not in present and kind in self.parameter_sets + ] + # An access unit delimiter stays the first NAL unit (H.265 7.4.2.4.4). + lead = 1 if nals and nal_type(nals[0]) == NAL_AUD else 0 + return nals[:lead] + missing + nals[lead:] + + def _drop(self) -> None: + if self._nals or self._fragment is not None or self._damaged: + self.dropped += 1 + self._reset() + + def _reset(self) -> None: + self._timestamp = None + self._nals = [] + self._size = 0 + self._fragment = None + self._damaged = False + + +def fragment(nal: bytes, max_payload: int) -> list[bytes]: + """Split one NAL unit into RFC 7798 fragmentation units.""" + kind = nal_type(nal) + header = bytes([(nal[0] & 0x81) | NAL_FU << 1, nal[1]]) + chunk = max_payload - 3 + body = nal[NAL_HEADER_SIZE:] + payloads = [] + for offset in range(0, len(body), chunk): + flags = (0x80 if offset == 0 else 0) | ( + 0x40 if offset + chunk >= len(body) else 0 + ) + payloads.append(header + bytes([flags | kind]) + body[offset : offset + chunk]) + return payloads + + +class HevcPacketizer: + """Packetizes access units into RFC 7798 RTP payloads (no DONL).""" + + def __init__(self, max_payload: int = 1200) -> None: + """Set the RTP payload budget.""" + if max_payload < 4: + raise ValueError("Payload budget too small for H.265") + self.max_payload = max_payload + + def packetize(self, nals: Iterable[bytes]) -> list[bytes]: + """Return the RTP payloads of one access unit, in order.""" + payloads: list[bytes] = [] + pending: list[bytes] = [] + for nal in nals: + if len(nal) > self.max_payload: + payloads += self._aggregate(pending) + pending = [] + payloads += self._fragment(nal) + continue + if self._ap_size([*pending, nal]) > self.max_payload: + payloads += self._aggregate(pending) + pending = [] + pending.append(nal) + payloads += self._aggregate(pending) + return payloads + + @staticmethod + def _ap_size(nals: list[bytes]) -> int: + return NAL_HEADER_SIZE + sum(2 + len(nal) for nal in nals) + + @staticmethod + def _aggregate(nals: list[bytes]) -> list[bytes]: + if len(nals) <= 1: + return list(nals) + # The AP header carries the lowest LayerId and TID (RFC 7798 4.4.2). + layer = min(((nal[0] & 1) << 5) | (nal[1] >> 3) for nal in nals) + tid = min(nal[1] & 7 for nal in nals) + header = bytes([NAL_AP << 1 | layer >> 5, (layer & 0x1F) << 3 | tid]) + return [header + b"".join(struct.pack(">H", len(n)) + n for n in nals)] + + def _fragment(self, nal: bytes) -> list[bytes]: + return fragment(nal, self.max_payload) diff --git a/pyhap/media/multi_tier.py b/pyhap/media/multi_tier.py new file mode 100644 index 00000000..76b927d6 --- /dev/null +++ b/pyhap/media/multi_tier.py @@ -0,0 +1,492 @@ +"""SRTP media of HomeKit multi-tier RTP sessions from a shared media source. + +A :class:`MultiTierSender` sends one camera stream to one controller on the +local network, as the Camera Multi-Tier RTP Stream Management service of a +HomeKit Secure Video 17.99 camera negotiates it. It reads the same +:class:`~pyhap.media.source.MediaSource` as the camera's other sessions, so a +camera channel is pulled once for all local and remote sessions; see +:class:`~pyhap.media.pool.SourcePool`. + +Video goes out as RFC 7798 H.265 (single NAL units, aggregation and +fragmentation units, no DONL) with the 90 kHz clock, the parameter sets +in-band before each IRAP picture as the source delivers them. Audio is the +source's Opus with the 48 kHz clock. Each packet gets the session's SSRC and +payload type and is protected with SRTP (AES_CM_128_HMAC_SHA1_80) with the +key of its stream; RTCP sender reports go out once a second as SRTCP. + +Session flow, mapped to the HAP operations: + +* Setup Endpoints: create the sender and :meth:`MultiTierSender.bind` it. + It binds the accessory's video and audio ports, which the answer + announces, and sends from them. +* Start: :meth:`MultiTierSender.start` with the source of the tier's + channel. Video starts with the next IRAP picture; ``on_keyframe_request`` + asks the channel's pull for one. +* Start again with another tier: :meth:`MultiTierSender.switch` to the + other channel's source. SSRCs, sequence numbers, RTP time and the SRTP + packet index continue. +* End: :meth:`MultiTierSender.close`. + +The controller sends its RTCP to the accessory's ports. Each datagram from +the controller's address that decrypts with the session key and does not +carry the accessory's own SSRC calls ``on_rtcp`` (at most once per +``rtcp_notify_interval``), which keeps the HAP session alive; a picture loss +indication or full intra request among them asks for a keyframe. The sender +ends on its own, calling ``on_closed(reason)``, when no video arrives for +``idle_timeout``, SRTP fails or the source is closed. A session starts, and +continues after a switch, with an IRAP picture that carries its parameter +sets. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import contextlib +import logging +import time +from typing import Any, cast + +import pylibsrtp + +from . import MediaError, rtp +from .hevc import AccessUnit, HevcPacketizer +from .source import MediaSource + +logger = logging.getLogger(__name__) + +# Keeps every RTP packet below a typical path MTU. +DEFAULT_MAX_PAYLOAD = 1200 + +DEFAULT_IDLE_TIMEOUT = 10.0 +RTCP_INTERVAL = 1.0 +RTCP_NOTIFY_INTERVAL = 1.0 +# Length of an AES_CM_128_HMAC_SHA1_80 master key and salt together. +SRTP_KEY_LENGTH = 30 + + +class _Port(asyncio.DatagramProtocol): + """One of the accessory's ports: sends media, receives the controller's RTCP.""" + + def __init__(self, on_datagram: Callable[[bytes, tuple[Any, ...]], None]) -> None: + self.transport: asyncio.DatagramTransport | None = None + self._on_datagram = on_datagram + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: bytes, addr: tuple[Any, ...]) -> None: + try: + self._on_datagram(data, addr) + except Exception: + logger.exception("Error handling a multi-tier RTCP packet") + + def error_received(self, exc: Exception) -> None: + # asyncio reports send errors here, e.g. ICMP unreachable; the idle + # timeout ends a session whose controller is gone. + logger.debug("Multi-tier port error: %s", exc) + + @property + def port(self) -> int: + if self.transport is None: + return 0 + return int(self.transport.get_extra_info("sockname")[1]) + + +class _SrtpStream: + """An outbound RTP stream and its SRTP context.""" + + def __init__(self, ssrc: int, payload_type: int, clock: int, key: bytes) -> None: + if len(key) != SRTP_KEY_LENGTH: + raise ValueError("SRTP key and salt must be 30 bytes") + self.rtp = rtp.OutboundStream(payload_type, clock) + self.rtp.ssrc = ssrc + self._outbound = pylibsrtp.Session( + pylibsrtp.Policy(key=key, ssrc_type=pylibsrtp.Policy.SSRC_ANY_OUTBOUND) + ) + self._inbound = pylibsrtp.Session( + pylibsrtp.Policy(key=key, ssrc_type=pylibsrtp.Policy.SSRC_ANY_INBOUND) + ) + + def packet(self, timestamp: int, marker: bool, payload: bytes) -> bytes: + packet = self.rtp.build(timestamp, marker, payload) + # Nothing is retransmitted, no history is kept. + self.rtp.history.clear() + return self._outbound.protect(packet) + + def report(self, cname: str) -> bytes | None: + report = self.rtp.sender_report() + if report is None: + return None + return self._outbound.protect_rtcp( + report + rtp.build_sdes(self.rtp.ssrc, cname) + ) + + def bye(self, cname: str) -> bytes: + # RFC 3550 6.1: a compound packet starts with a report and has a CNAME; + # a source that has sent nothing reports as a receiver. + report = self.rtp.sender_report() or rtp.build_receiver_report(self.rtp.ssrc) + return self._outbound.protect_rtcp( + report + + rtp.build_sdes(self.rtp.ssrc, cname) + + rtp.build_bye([self.rtp.ssrc]) + ) + + def unprotect_rtcp(self, data: bytes) -> bytes | None: + """The controller's RTCP, None unless it is SRTCP from another SSRC. + + Both directions share the key, so the accessory's own packets sent + back would decrypt too; they carry the accessory's SSRC. + """ + try: + rtcp = self._inbound.unprotect_rtcp(data) + except pylibsrtp.Error: + return None + if len(rtcp) < 8 or int.from_bytes(rtcp[4:8], "big") == self.rtp.ssrc: + return None + return rtcp + + def authentic(self, data: bytes) -> bool: + """Whether ``data`` is the controller's SRTCP or SRTP under the key.""" + if self.unprotect_rtcp(data) is not None: + return True + try: + packet = self._inbound.unprotect(data) + except pylibsrtp.Error: + return False + return len(packet) >= 12 and int.from_bytes(packet[8:12], "big") != ( + self.rtp.ssrc + ) + + +class MultiTierSender: + """Sends one camera stream to one controller as SRTP, see the module docs. + + Callbacks, all optional and called on the event loop: + + * ``on_rtcp()``: the controller is still there; keep its session alive. + * ``on_keyframe_request(age, gop)``: the session needs an IRAP picture; + ``age`` is the time since the source last started one and ``gop`` the + interval between them the source measured (each None until known). + * ``on_closed(reason)``: the sender ended on its own. Not called for + :meth:`close`. + """ + + def __init__( + self, + *, + address: str, + video_port: int, + audio_port: int, + video_key: bytes, + audio_key: bytes, + video_ssrc: int, + audio_ssrc: int, + video_payload_type: int = rtp.DEFAULT_VIDEO_PAYLOAD_TYPE, + audio_payload_type: int = rtp.DEFAULT_AUDIO_PAYLOAD_TYPE, + local_video_port: int = 0, + local_audio_port: int = 0, + is_ipv6: bool | None = None, + max_payload: int = DEFAULT_MAX_PAYLOAD, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + keyframe_request_interval: float = rtp.KEYFRAME_REQUEST_INTERVAL, + rtcp_notify_interval: float = RTCP_NOTIFY_INTERVAL, + ) -> None: + """Configure the session; :meth:`bind` opens the ports. + + ``address`` and the ports are where the controller receives. The + keys are the SRTP master key and salt (30 bytes) of each stream, as + Setup Endpoints carries them. Local ports of 0 bind free ones. + """ + self.address = address.split("%", 1)[0] + self.is_ipv6 = ":" in self.address if is_ipv6 is None else is_ipv6 + self.on_rtcp: Callable[[], None] | None = None + self.on_keyframe_request: ( + Callable[[float | None, float | None], None] | None + ) = None + self.on_closed: Callable[[str], None] | None = None + self._destination = (video_port, audio_port) + self._local = (local_video_port, local_audio_port) + self._video = _SrtpStream( + video_ssrc, video_payload_type, rtp.VIDEO_CLOCK_RATE, video_key + ) + self._audio = _SrtpStream( + audio_ssrc, audio_payload_type, rtp.AUDIO_CLOCK_RATE, audio_key + ) + self._cname = f"pyhap-{video_ssrc:08x}" + self._packetizer = HevcPacketizer(max_payload) + self._closing: asyncio.Task[None] | None = None + self._idle_timeout = idle_timeout + self._keyframe_request_interval = keyframe_request_interval + self._rtcp_notify_interval = rtcp_notify_interval + self._video_port: _Port | None = None + self._audio_port: _Port | None = None + self._source: MediaSource | None = None + self._send_audio = False + self._waiting_for_keyframe = True + self._last_keyframe_request = -keyframe_request_interval + self._last_rtcp_notify = -rtcp_notify_interval + self._last_audio_input = 0.0 + self._started_at = 0.0 + self._tasks: set[asyncio.Task[None]] = set() + self._started = False + self._closed = False + + @property + def ports(self) -> tuple[int, int]: + """The accessory's ``(video_port, audio_port)`` once bound.""" + if self._video_port is None or self._audio_port is None: + return self._local + return self._video_port.port, self._audio_port.port + + @property + def source(self) -> MediaSource | None: + """The source the sender reads, once started.""" + return self._source + + @property + def started(self) -> bool: + """Whether media flows to the controller.""" + return self._started and not self._closed + + @property + def closed(self) -> bool: + """Whether the session ended.""" + return self._closed + + @property + def wants_media(self) -> bool: + """Whether the source should reassemble media for this session.""" + return self.started + + async def bind(self) -> tuple[int, int]: + """Bind the accessory's ports on every interface and return them. + + :raises OSError: If a port cannot be bound. + """ + if self._closed: + raise MediaError("Multi-tier session closed") + if self._video_port is not None: + return self.ports + loop = asyncio.get_running_loop() + # Any interface: the controller reaches the accessory on its LAN + # address; only the controller's datagrams are read. + any_address = "::" if self.is_ipv6 else "0.0.0.0" # noqa: S104 + try: + _, self._video_port = await loop.create_datagram_endpoint( + lambda: _Port(self._on_video_port), + local_addr=(any_address, self._local[0]), + ) + if not self._closed: + _, self._audio_port = await loop.create_datagram_endpoint( + lambda: _Port(self._on_audio_port), + local_addr=(any_address, self._local[1]), + ) + except BaseException: + self._close_ports() + raise + if self._closed: + # Closed while binding: _close found no ports to release. + self._close_ports() + raise MediaError("Multi-tier session closed") + return self.ports + + async def start(self, source: MediaSource, audio: bool = True) -> None: + """Send ``source`` to the controller, with its audio if ``audio``.""" + if self._closed: + raise MediaError("Multi-tier session closed") + if self._video_port is None: + await self.bind() + if self._started: + raise MediaError("Multi-tier session already started") + self._send_audio = audio + self._attach(source) + self._started = True + self._started_at = time.monotonic() + self._spawn(self._rtcp_loop()) + self._request_keyframe() + + async def switch(self, source: MediaSource) -> None: + """Continue the stream from another source, e.g. for another tier.""" + if not self.started: + raise MediaError("Multi-tier session not started") + if source is self._source: + return + self._attach(source) + self._started_at = time.monotonic() + # The new source needs its own request, however recent the last one. + self._last_keyframe_request = -self._keyframe_request_interval + self._request_keyframe() + + def _attach(self, source: MediaSource) -> None: + if source.closed: + raise MediaError("Media source is closed") + if self._source is not None: + self._source.detach(self) + self._source = source + self._waiting_for_keyframe = True + # Another source has its own timestamps; the stream goes on smoothly. + self._video.rtp.rebase() + self._audio.rtp.rebase() + source.attach(self) + + async def close(self) -> None: + """Stop sending and release the ports, without calling ``on_closed``.""" + if self._closing is not None: + # Ending on its own already; wait until the ports are released. + await asyncio.shield(self._closing) + return + await self._close(None) + + async def _close(self, reason: str | None) -> None: + if self._closed: + return + self._closed = True + if reason is not None: + logger.debug("Multi-tier session closed: %s", reason) + if self._started: + self._send(self._video_port, self._video.bye(self._cname), 0) + if self._send_audio: + self._send(self._audio_port, self._audio.bye(self._cname), 1) + if self._source is not None: + self._source.detach(self) + current = asyncio.current_task() + tasks = [task for task in self._tasks if task is not current] + for task in tasks: + task.cancel() + try: + await asyncio.gather(*tasks, return_exceptions=True) + finally: + self._close_ports() + if reason is not None and self.on_closed: + try: + self.on_closed(reason) + except Exception: + logger.exception("Error in multi-tier close callback") + + def _close_ports(self) -> None: + for port in (self._video_port, self._audio_port): + if port is not None and port.transport is not None: + port.transport.close() + + def _close_soon(self, reason: str) -> None: + if not self._closed and self._closing is None: + self._closing = asyncio.get_running_loop().create_task(self._close(reason)) + + def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + task = asyncio.get_running_loop().create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + def _notify(self, callback: Callable[..., None] | None, *args: Any) -> None: + if callback is None: + return + try: + callback(*args) + except Exception: + logger.exception("Error in multi-tier callback") + + def _send(self, port: _Port | None, packet: bytes, index: int) -> None: + if port is None or port.transport is None or port.transport.is_closing(): + return + port.transport.sendto(packet, (self.address, self._destination[index])) + + def on_source_closed(self, reason: str) -> None: + """The media source ended: end the session.""" + self._close_soon(f"media source ended: {reason}") + + def on_source_video(self, unit: AccessUnit, ssrc: int) -> None: + """Send one access unit, starting with an IRAP picture.""" + if not self.started: + return + if self._waiting_for_keyframe and not unit.self_contained: + # Start where a decoder can: an IRAP picture with its parameters. + return + self._waiting_for_keyframe = False + timestamp = self._video.rtp.timestamp(ssrc, unit.timestamp) + payloads = self._packetizer.packetize(unit.nals) + last = len(payloads) - 1 + try: + for index, payload in enumerate(payloads): + packet = self._video.packet(timestamp, index == last, payload) + self._send(self._video_port, packet, 0) + except pylibsrtp.Error as err: + self._close_soon(f"send failed: {err!r}") + + def on_source_audio(self, packet: rtp.RtpPacket) -> None: + """Send one Opus packet.""" + now = time.monotonic() + marker = now - self._last_audio_input > rtp.AUDIO_TALKSPURT_GAP + self._last_audio_input = now + if not self.started or not self._send_audio: + return + timestamp = self._audio.rtp.timestamp(packet.ssrc, packet.timestamp) + try: + data = self._audio.packet(timestamp, marker, packet.payload) + self._send(self._audio_port, data, 1) + except pylibsrtp.Error as err: + self._close_soon(f"send failed: {err!r}") + + def _from_controller(self, addr: tuple[Any, ...]) -> bool: + # The controller's address has no scope ID, a received one may. + return str(addr[0]).split("%", 1)[0] == self.address + + def _on_video_port(self, data: bytes, addr: tuple[Any, ...]) -> None: + if not self._from_controller(addr): + return + rtcp = self._video.unprotect_rtcp(data) + if rtcp is None: + # A spoofed source address alone must not keep the session alive. + return + self._controller_alive() + for message in rtp.parse_rtcp(rtcp): + if ( + message.is_keyframe_request + and self._video.rtp.ssrc in message.media_ssrcs + ): + self._request_keyframe() + + def _on_audio_port(self, data: bytes, addr: tuple[Any, ...]) -> None: + if self._from_controller(addr) and self._audio.authentic(data): + self._controller_alive() + + def _controller_alive(self) -> None: + now = time.monotonic() + if now - self._last_rtcp_notify < self._rtcp_notify_interval: + return + self._last_rtcp_notify = now + self._notify(self.on_rtcp) + + def _request_keyframe(self) -> None: + now = time.monotonic() + if now - self._last_keyframe_request < self._keyframe_request_interval: + return + self._last_keyframe_request = now + source = self._source + age = source.keyframe_age() if source else None + self._notify(self.on_keyframe_request, age, source.gop if source else None) + + async def _rtcp_loop(self) -> None: + cname = self._cname + try: + while True: + await asyncio.sleep(RTCP_INTERVAL) + source = self._source + last_input = max( + self._started_at, source.last_video_input if source else 0.0 + ) + if time.monotonic() - last_input > self._idle_timeout: + await self._close("media input idle") + return + if self._waiting_for_keyframe: + self._request_keyframe() + if report := self._video.report(cname): + self._send(self._video_port, report, 0) + if self._send_audio and (report := self._audio.report(cname)): + self._send(self._audio_port, report, 1) + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - a broken path ends the session + logger.debug("Multi-tier RTCP send failed", exc_info=True) + with contextlib.suppress(Exception): + await self._close(f"rtcp send failed: {err!r}") diff --git a/pyhap/media/multi_tier_delegate.py b/pyhap/media/multi_tier_delegate.py new file mode 100644 index 00000000..3e064ce3 --- /dev/null +++ b/pyhap/media/multi_tier_delegate.py @@ -0,0 +1,275 @@ +"""Run the media of multi-tier RTP sessions from shared media sources. + +:class:`MultiTierMediaDelegate` is a +:class:`pyhap.camera_multi_tier.MultiTierDelegate` with one +:class:`~pyhap.media.multi_tier.MultiTierSender` per session. It leases +sources from a provider and its pool, which the camera's other sessions can +share, so a camera channel is pulled once for all of them:: + + worker = MediaWorker() + provider = FFmpegSourceProvider({"high": [...], "medium": [...]}, worker=worker) + camera = Camera( + { + ..., + "multi_tier": {"delegate": MultiTierMediaDelegate(provider)}, + }, + driver, + "Camera", + ) + +Setup Endpoints binds the session's ports in the pool's worker (or in +process) and announces them; the start leases the source of the tier's +channel, starts the sender on it and pulls the channel if no other session +does. A start with another tier moves the session to that tier's channel; +SSRCs, sequence numbers and SRTP state continue. The controller's RTCP keeps +the session alive (:meth:`~pyhap.camera_multi_tier.MultiTierSession.keep_alive`); +a sender that ends on its own ends its HAP session. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from functools import partial +import logging +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from pyhap import hksv3 +from pyhap.camera_stream import StreamError + +from .pool import MultiTierMediaSender, SourceLease, SourcePool + +if TYPE_CHECKING: + from pyhap.camera_multi_tier import MultiTierSession + +logger = logging.getLogger(__name__) + +_SETUP_ERROR = hksv3.SetupEndpointsStatus.ERROR +_ERROR = hksv3.RTPStreamingStatus.ERROR + +MultiTierSourceProvider = Callable[ + ["MultiTierSession", hksv3.VideoStreamTier, "hksv3.AudioStreamTier | None"], + Awaitable[SourceLease], +] + + +@runtime_checkable +class PooledSourceProvider(Protocol): + """A source provider that names its pool, e.g. an + :class:`~pyhap.media.ffmpeg.FFmpegSourceProvider`. + """ + + pool: SourcePool + + +# MultiTierSender options a delegate user may set; the session sets the rest. +_SENDER_OPTIONS = frozenset( + { + "local_video_port", + "local_audio_port", + "max_payload", + "idle_timeout", + "keyframe_request_interval", + "rtcp_notify_interval", + } +) + + +@dataclass(eq=False) +class _Media: + """What the delegate holds for one session.""" + + session: MultiTierSession + sender: MultiTierMediaSender + lease: SourceLease | None = None + finished: bool = False + + +class MultiTierMediaDelegate: + """A ``MultiTierDelegate`` that streams from a :class:`SourcePool`. + + ``source_provider`` leases the source of a tier's channel, for example + an :class:`~pyhap.media.ffmpeg.FFmpegSourceProvider`; ``pool`` is the + pool its leases come from and defaults to the provider's ``pool``. + ``sender_options`` go to every + :class:`~pyhap.media.multi_tier.MultiTierSender`, e.g. ``idle_timeout`` + and ``max_payload``. + + A media worker should be started before the first session: starting it + takes longer than a HAP hook may. + """ + + def __init__( + self, + source_provider: MultiTierSourceProvider, + pool: SourcePool | None = None, + *, + sender_options: Mapping[str, Any] | None = None, + ) -> None: + """Configure the delegate.""" + if pool is None and isinstance(source_provider, PooledSourceProvider): + pool = source_provider.pool + if not isinstance(pool, SourcePool): + raise TypeError("MultiTierMediaDelegate needs the provider's SourcePool") + self.source_provider = source_provider + self.pool = pool + self.sender_options = dict(sender_options or {}) + if unknown := sorted(set(self.sender_options) - _SENDER_OPTIONS): + raise TypeError(f"Unknown sender_options: {', '.join(unknown)}") + self._media: dict[MultiTierSession, _Media] = {} + + @property + def session_count(self) -> int: + """Sessions the delegate holds media for.""" + return len(self._media) + + def sender(self, session: MultiTierSession) -> MultiTierMediaSender | None: + """The sender of a session, None if there is none.""" + media = self._media.get(session) + return None if media is None else media.sender + + async def handle_prepare(self, session: MultiTierSession) -> None: + """Bind the session's ports and announce them.""" + sender = self.pool.create_multi_tier_sender( + **self.sender_options, + address=session.address, + is_ipv6=session.is_ipv6, + video_port=session.video_port, + audio_port=session.audio_port, + video_key=session.video_srtp_key + session.video_srtp_salt, + audio_key=session.audio_srtp_key + session.audio_srtp_salt, + video_ssrc=session.video_ssrc, + audio_ssrc=session.audio_ssrc, + video_payload_type=hksv3.VIDEO_PAYLOAD_TYPE, + audio_payload_type=hksv3.AUDIO_PAYLOAD_TYPE, + ) + media = self._media[session] = _Media(session, sender) + sender.on_rtcp = session.keep_alive + sender.on_closed = partial(self._sender_closed, media) + sender.on_keyframe_request = partial(self._keyframe_requested, media) + try: + ports = await sender.bind() + except Exception as err: + raise StreamError(_SETUP_ERROR, f"Ports not bound: {err}") from err + session.accessory_video_port, session.accessory_audio_port = ports + + def _get(self, session: MultiTierSession) -> _Media: + media = self._media.get(session) + if media is None or media.finished: + raise StreamError(_ERROR, "No multi-tier media for the session") + return media + + async def _lease( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> SourceLease: + try: + return await self.source_provider(session, video_tier, audio_tier) + except Exception as err: + # A provider's status may be one of another service, e.g. the + # WebRTC offer's; a start answers with its own error. + logger.warning("[%s] No multi-tier media source: %s", session.id, err) + raise StreamError(_ERROR, f"No media source: {err}") from err + + async def handle_start( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Lease the tier's source, pull it and start the sender on it.""" + media = self._get(session) + if (old := media.lease) is not None: + # A repeated start after a failed one: give up the earlier claim. + media.lease = None + await old.release() + lease = await self._lease(session, video_tier, audio_tier) + media.lease = lease + try: + # Pull first: a failed pull leaves the sender unstarted, so a + # repeated start can still start it; its keyframe request also + # reaches the pull. + await lease.activate() + await media.sender.start(lease.source, audio=audio_tier is not None) + except BaseException as err: + media.lease = None + await lease.release() + if not isinstance(err, Exception): + raise + raise StreamError(_ERROR, f"Stream not started: {err}") from err + logger.debug( + "[%s] Multi-tier stream of %dx%d@%d started", + session.id, + video_tier.width, + video_tier.height, + video_tier.frame_rate, + ) + + async def handle_reconfigure( + self, + session: MultiTierSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier | None, + ) -> None: + """Move the session to the source of the new tier's channel. + + Audio stays as the start set it. + """ + media = self._get(session) + old = media.lease + lease = await self._lease(session, video_tier, audio_tier) + if old is not None and lease.source is old.source: + await lease.release() + logger.debug("[%s] Tier %s uses the same source", session.id, video_tier) + return + # The switch asks the new source for a keyframe at once, through the + # session's lease: it must already be the new one. + media.lease = lease + try: + await lease.activate() + await media.sender.switch(lease.source) + except BaseException as err: + # Also when the hook is cancelled, e.g. on a timeout. + media.lease = old + await lease.release() + if not isinstance(err, Exception): + raise + raise StreamError(_ERROR, f"Tier not changed: {err}") from err + if old is not None: + await old.release() + logger.debug("[%s] Multi-tier stream moved to %s", session.id, video_tier) + + async def handle_end(self, session: MultiTierSession) -> None: + """Close the sender and release the lease.""" + media = self._media.pop(session, None) + if media is not None: + await self._finish(media) + + async def _finish(self, media: _Media) -> None: + if media.finished: + return + media.finished = True + try: + await media.sender.close() + finally: + if media.lease is not None: + await media.lease.release() + logger.debug("[%s] Multi-tier media released", media.session.id) + + def _keyframe_requested( + self, media: _Media, age: float | None, gop: float | None + ) -> None: + if media.lease is not None: + media.lease.keyframe_requested(age, gop) + + def _sender_closed(self, media: _Media, reason: str) -> None: + logger.info("[%s] Multi-tier stream ended: %s", media.session.id, reason) + media.session.end(f"multi-tier media ended: {reason}") + + async def close(self) -> None: + """Release every session left.""" + media, self._media = list(self._media.values()), {} + await asyncio.gather(*(self._finish(m) for m in media)) diff --git a/pyhap/media/pool.py b/pyhap/media/pool.py new file mode 100644 index 00000000..dc2c28cd --- /dev/null +++ b/pyhap/media/pool.py @@ -0,0 +1,360 @@ +"""Media sources shared by the sessions of a camera channel. + +:class:`SourcePool` gives each multi-tier session a +:class:`ChannelLease` on the media source of its camera channel. All +sessions of a channel share one source and one media pull +(:class:`ChannelPull`, for example an ffmpeg process), so N sessions cost one +camera connection. Sources live in a +:class:`~pyhap.media.worker.MediaWorker`, or in process without one; +a multi-tier sender is created in the same place. + +A multi-tier sender binds its ports before the controller picks a tier, so +it is created by the pool (:meth:`SourcePool.create_multi_tier_sender`) and +started with a lease's source later. + +The ports of a source are bound by the first lease; the pull starts with the +first answered session (:meth:`ChannelLease.activate`) and stops with the +last one; the source is closed with the last lease. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine, Hashable +from dataclasses import dataclass, field +from functools import partial +import inspect +import logging +from typing import TYPE_CHECKING, Any, Protocol, cast + +from . import MediaError + +if TYPE_CHECKING: + from .worker import MediaWorker + +logger = logging.getLogger(__name__) + + +class MediaSourceLike(Protocol): + """A :class:`~pyhap.media.source.MediaSource` or its worker proxy.""" + + on_closed: Callable[[str], None] | None + + @property + def closed(self) -> bool: + """Whether the source has ended.""" + + @property + def rtp_input(self) -> tuple[str, int, int]: + """``(host, video_port, audio_port)`` the pull sends to.""" + + @property + def has_audio(self) -> bool: + """Whether the source takes audio, i.e. has an audio input port.""" + + def close(self, reason: str = "closed") -> Awaitable[None] | None: + """Release the ports and end every session on the source.""" + + +class MultiTierMediaSender(Protocol): + """A :class:`~pyhap.media.multi_tier.MultiTierSender` or its worker proxy.""" + + on_closed: Callable[[str], None] | None + on_keyframe_request: Callable[[float | None, float | None], None] | None + on_rtcp: Callable[[], None] | None + + async def bind(self) -> tuple[int, int]: + """Bind the accessory's video and audio ports and return them.""" + + async def start(self, source: MediaSourceLike, audio: bool = True) -> None: + """Send ``source``, a source of the same pool, to the controller.""" + + async def switch(self, source: MediaSourceLike) -> None: + """Continue the stream from another source of the same pool.""" + + async def close(self) -> None: + """End the session.""" + + +class ChannelPull(Protocol): + """The media pull of one camera channel, for example an ffmpeg process.""" + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Start sending plain RTP to ``(host, video_port, audio_port)``.""" + + async def stop(self) -> None: + """Stop sending; called once after a successful :meth:`start`.""" + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """A session needs an IRAP picture.""" + + +class SourceLease(Protocol): + """A session's claim on the media source of its camera channel.""" + + @property + def source(self) -> MediaSourceLike: + """The shared source.""" + + async def activate(self) -> None: + """The session was answered: the channel must be pulled now.""" + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Forward a keyframe request of the session's sender.""" + + async def release(self) -> None: + """The session ended; called exactly once.""" + + +@dataclass(eq=False) +class _Channel: + key: Hashable + name: str + source: MediaSourceLike + pull: ChannelPull + leases: int = 0 + active: int = 0 + pulling: bool = False + closed: bool = False + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class ChannelLease: + """A :class:`SourceLease` of a :class:`SourcePool`.""" + + def __init__(self, pool: SourcePool, channel: _Channel) -> None: + """Use :meth:`SourcePool.lease`.""" + self._pool = pool + self._channel = channel + self.active = False + self.released = False + + @property + def source(self) -> MediaSourceLike: + """The channel's source.""" + return self._channel.source + + @property + def channel(self) -> str: + """The channel's name.""" + return self._channel.name + + async def activate(self) -> None: + """Count the session as answered; the first one starts the pull.""" + await self._pool._activate(self) + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Pass the request on while the channel is pulled.""" + channel = self._channel + if self.active and channel.pulling and not channel.closed: + channel.pull.keyframe_requested(age, gop) + + async def release(self) -> None: + """Give up the claim; the last session stops the pull and the source.""" + await asyncio.shield(self._pool._release(self)) + + +class SourcePool: + """Shares one media source and one pull per camera channel. + + A channel is any hashable key; leases of equal keys share the source. + Sources and senders run in ``worker``, or in process without one. + """ + + def __init__( + self, worker: MediaWorker | None = None, host: str = "127.0.0.1" + ) -> None: + """Configure the pool; sources are opened by the first lease.""" + self.worker = worker + self.host = host + self._channels: dict[Hashable, _Channel] = {} + self._lock = asyncio.Lock() + self._tasks: set[asyncio.Task[Any]] = set() + + def create_multi_tier_sender(self, **options: Any) -> MultiTierMediaSender: + """Create a multi-tier sender where the pool's sources run. + + ``options`` are those of + :class:`~pyhap.media.multi_tier.MultiTierSender`. Start it with the + source of a lease of this pool. The pool creates it, not a lease: its ports + are announced in Setup Endpoints, before the controller picks the + tier, i.e. the lease. + """ + if self.worker is not None: + return cast( + MultiTierMediaSender, self.worker.create_multi_tier_sender(**options) + ) + from .multi_tier import MultiTierSender # noqa: PLC0415 + + return cast(MultiTierMediaSender, MultiTierSender(**options)) + + def counts(self) -> dict[str, tuple[int, int]]: + """Leases and answered leases per channel name.""" + return {c.name: (c.leases, c.active) for c in self._channels.values()} + + async def lease( + self, + channel: Hashable, + pull: Callable[[], ChannelPull], + *, + audio: bool = True, + name: str | None = None, + ) -> ChannelLease: + """Lease the source of ``channel``, opening it with ``pull`` if needed. + + ``pull`` makes the channel's pull when a new source is opened. + ``name`` is what the log shows instead of the key. + """ + task = asyncio.ensure_future(self._lease(channel, pull, audio, name)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + # The lease is made anyway; give it back once it is. + task.add_done_callback(self._release_orphan) + raise + + async def _lease( + self, + key: Hashable, + pull: Callable[[], ChannelPull], + audio: bool, + name: str | None, + ) -> ChannelLease: + async with self._lock: + channel = self._channels.get(key) + if channel is None: + # Made first: a failing factory leaves no open source behind. + channel_pull = pull() + source = await self._open_source(audio) + channel = _Channel(key, name or str(key), source, channel_pull) + source.on_closed = partial(self._source_closed, channel) + if source.closed: + # It ended before on_closed was set, nothing will report it. + raise MediaError(f"Media source {channel.name} ended") + self._channels[key] = channel + logger.debug("Source %s opened at %s", channel.name, source.rtp_input) + channel.leases += 1 + self._log(channel) + return ChannelLease(self, channel) + + async def _open_source(self, audio: bool) -> MediaSourceLike: + if self.worker is not None: + return await self.worker.open_source(self.host, audio) + from .source import MediaSource # noqa: PLC0415 + + source = MediaSource(self.host, audio) + try: + await source.start() + except BaseException: + source.close("start failed") + raise + return source + + def _release_orphan(self, task: asyncio.Task[ChannelLease]) -> None: + if not task.cancelled() and task.exception() is None: + self._spawn(task.result().release()) + + def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + task = asyncio.ensure_future(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _activate(self, lease: ChannelLease) -> None: + channel = lease._channel + if lease.released or lease.active: + return + if channel.closed: + raise MediaError(f"Media source {channel.name} is closed") + lease.active = True + channel.active += 1 + try: + await self._sync_pull(channel) + except BaseException: + # A release while the pull started has already counted it off. + if lease.active: + lease.active = False + channel.active -= 1 + raise + self._log(channel) + + async def _release(self, lease: ChannelLease) -> None: + if lease.released: + return + lease.released = True + channel = lease._channel + channel.leases -= 1 + if lease.active: + lease.active = False + channel.active -= 1 + last = channel.leases == 0 and not channel.closed + if last: + # From here on a new lease opens a new source. + channel.closed = True + self._forget(channel) + self._log(channel) + await self._sync_pull(channel) + if last: + await self._close_source(channel, "no sessions left") + + async def _sync_pull(self, channel: _Channel) -> None: + """Start or stop the pull so it runs exactly while sessions are answered.""" + async with channel.lock: + wanted = channel.active > 0 and not channel.closed + if wanted and not channel.pulling: + logger.debug("Starting the pull of source %s", channel.name) + await channel.pull.start(channel.source.rtp_input) + channel.pulling = True + elif not wanted and channel.pulling: + channel.pulling = False + logger.debug("Stopping the pull of source %s", channel.name) + try: + await channel.pull.stop() + except Exception: + logger.exception("Stopping the pull of %s failed", channel.name) + + async def _close_source(self, channel: _Channel, reason: str) -> None: + source = channel.source + source.on_closed = None + try: + result = source.close(reason) + if inspect.isawaitable(result): + await result + except Exception: + logger.exception("Closing source %s failed", channel.name) + logger.debug("Source %s closed: %s", channel.name, reason) + + def _forget(self, channel: _Channel) -> None: + if self._channels.get(channel.key) is channel: + del self._channels[channel.key] + + def _source_closed(self, channel: _Channel, reason: str) -> None: + """The source ended on its own, e.g. its worker exited.""" + if channel.closed: + return + logger.warning("Source %s ended: %s", channel.name, reason) + channel.closed = True + self._forget(channel) + self._spawn(self._sync_pull(channel)) + + def _log(self, channel: _Channel) -> None: + logger.debug( + "Source %s: %d leases, %d answered%s", + channel.name, + channel.leases, + channel.active, + ", closed" if channel.closed else "", + ) + + async def close(self) -> None: + """Stop every pull and close every source, e.g. on shutdown.""" + # A lease being opened finishes first, so its channel is closed too. + async with self._lock: + channels = list(self._channels.values()) + self._channels.clear() + for channel in channels: + channel.closed = True + for channel in channels: + await self._sync_pull(channel) + await self._close_source(channel, "pool closed") + while self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) diff --git a/pyhap/media/rtp.py b/pyhap/media/rtp.py new file mode 100644 index 00000000..26ac69e8 --- /dev/null +++ b/pyhap/media/rtp.py @@ -0,0 +1,310 @@ +"""Minimal RTP and RTCP handling for the camera media senders (RFC 3550, RFC 4585).""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Iterable +from dataclasses import dataclass, field +import secrets +import struct +import time + +# RTP clock rates of H.265 video and Opus audio (RFC 7798, RFC 7587). +VIDEO_CLOCK_RATE = 90000 +AUDIO_CLOCK_RATE = 48000 +# Dynamic payload types the HomeKit streams use for H.265 and Opus. +DEFAULT_VIDEO_PAYLOAD_TYPE = 99 +DEFAULT_AUDIO_PAYLOAD_TYPE = 110 + +# Shortest interval between keyframe requests to a source. +KEYFRAME_REQUEST_INTERVAL = 1.0 +# An audio gap this long starts a new talkspurt (RTP marker bit). +AUDIO_TALKSPURT_GAP = 1.0 +# Sent packets kept for NACK retransmission. +RETRANSMIT_HISTORY = 1024 + + +RTP_VERSION = 2 +RTP_HEADER = 12 +RTCP_SR = 200 +RTCP_RR = 201 +RTCP_SDES = 202 +RTCP_BYE = 203 +RTCP_RTPFB = 205 +RTCP_PSFB = 206 +FMT_NACK = 1 +FMT_PLI = 1 +FMT_FIR = 4 +NTP_EPOCH_OFFSET = 2208988800 +# Payload types 64-95 collide with RTCP packet types (RFC 5761 section 4). +RTCP_PAYLOAD_TYPES = range(64, 96) + + +@dataclass +class RtpPacket: + """A parsed RTP packet; header extensions and CSRCs are dropped.""" + + payload_type: int + sequence: int + timestamp: int + ssrc: int + marker: bool + payload: bytes + + @property + def is_rtcp(self) -> bool: + """Whether this "RTP" packet is really RTCP sent to an RTP port.""" + return self.payload_type in RTCP_PAYLOAD_TYPES + + +def parse_rtp(packet: bytes) -> RtpPacket: + """Parse an RTP packet, skipping CSRCs, extension and padding.""" + if len(packet) < RTP_HEADER or packet[0] >> 6 != RTP_VERSION: + raise ValueError("Not an RTP packet") + first, second, sequence, timestamp, ssrc = struct.unpack_from(">BBHII", packet) + offset = RTP_HEADER + 4 * (first & 0x0F) + if first & 0x10: + if offset + 4 > len(packet): + raise ValueError("Truncated RTP header extension") + (words,) = struct.unpack_from(">H", packet, offset + 2) + offset += 4 + 4 * words + end = len(packet) + if first & 0x20: + if end == 0 or packet[-1] == 0: + raise ValueError("Invalid RTP padding") + end -= packet[-1] + if offset > end: + raise ValueError("Truncated RTP packet") + return RtpPacket( + payload_type=second & 0x7F, + sequence=sequence, + timestamp=timestamp, + ssrc=ssrc, + marker=bool(second & 0x80), + payload=packet[offset:end], + ) + + +def build_rtp(packet: RtpPacket) -> bytes: + """Serialize an RTP packet without CSRCs, extension or padding.""" + second = (0x80 if packet.marker else 0) | packet.payload_type + header = struct.pack( + ">BBHII", + RTP_VERSION << 6, + second, + packet.sequence & 0xFFFF, + packet.timestamp & 0xFFFFFFFF, + packet.ssrc, + ) + return header + packet.payload + + +@dataclass +class RtcpMessage: + """The RTCP fields the sender acts on.""" + + packet_type: int + fmt: int + sender_ssrc: int + media_ssrcs: list[int] = field(default_factory=list) + lost: list[int] = field(default_factory=list) + + @property + def is_keyframe_request(self) -> bool: + """Whether this is a PLI or FIR.""" + return self.packet_type == RTCP_PSFB and self.fmt in (FMT_PLI, FMT_FIR) + + @property + def is_nack(self) -> bool: + """Whether this is a generic NACK.""" + return self.packet_type == RTCP_RTPFB and self.fmt == FMT_NACK + + +def parse_rtcp(data: bytes) -> list[RtcpMessage]: + """Parse a compound RTCP packet; stops at the first malformed part. + + Every part must be version 2, fit in ``data`` and carry at least the + sender SSRC (RFC 3550 section 6.4), except a BYE without sources, whose + ``sender_ssrc`` is 0. + """ + messages = [] + offset = 0 + while offset + 4 <= len(data): + first, packet_type, length = struct.unpack_from(">BBH", data, offset) + end = offset + 4 * (length + 1) + if first >> 6 != RTP_VERSION or end > len(data): + break + if length == 0: + # Only a BYE without sources is just a header (RFC 3550 6.6). + if packet_type != RTCP_BYE or first & 0x1F: + break + messages.append(RtcpMessage(packet_type, 0, 0)) + else: + body = data[offset + 4 : end] + messages.append(_parse_one(first & 0x1F, packet_type, body)) + offset = end + return messages + + +def _parse_one(fmt: int, packet_type: int, body: bytes) -> RtcpMessage: + (sender,) = struct.unpack_from(">I", body) + message = RtcpMessage(packet_type, fmt, sender) + if packet_type == RTCP_BYE: + message.media_ssrcs = list( + struct.unpack_from(f">{min(fmt, len(body) // 4)}I", body) + ) + elif packet_type in (RTCP_RTPFB, RTCP_PSFB) and len(body) >= 8: + (media,) = struct.unpack_from(">I", body, 4) + fci = body[8:] + if packet_type == RTCP_PSFB and fmt == FMT_FIR: + # FIR names its targets in the FCI (RFC 5104 section 4.3.1). + message.media_ssrcs = [ + struct.unpack_from(">I", fci, i)[0] for i in range(0, len(fci) - 7, 8) + ] + else: + message.media_ssrcs = [media] + if message.is_nack: + message.lost = _nack_sequences(fci) + return message + + +def _nack_sequences(fci: bytes) -> list[int]: + lost: list[int] = [] + for i in range(0, len(fci) - 3, 4): + pid: int + blp: int + pid, blp = struct.unpack_from(">HH", fci, i) + lost.append(pid) + lost += [(pid + bit + 1) & 0xFFFF for bit in range(16) if blp >> bit & 1] + return lost + + +def ntp_time(now: float | None = None) -> int: + """Return a 64-bit NTP timestamp for a Unix time.""" + now = time.time() if now is None else now + seconds = int(now) + NTP_EPOCH_OFFSET + fraction = int((now % 1) * (1 << 32)) + return (seconds & 0xFFFFFFFF) << 32 | fraction + + +def build_sender_report( + ssrc: int, ntp: int, rtp_timestamp: int, packets: int, octets: int +) -> bytes: + """Build an RTCP SR without report blocks.""" + return struct.pack( + ">BBHIQIII", + RTP_VERSION << 6, + RTCP_SR, + 6, + ssrc, + ntp, + rtp_timestamp & 0xFFFFFFFF, + packets & 0xFFFFFFFF, + octets & 0xFFFFFFFF, + ) + + +def build_receiver_report(ssrc: int) -> bytes: + """Build an RTCP RR without report blocks.""" + return struct.pack(">BBHI", RTP_VERSION << 6, RTCP_RR, 1, ssrc) + + +def build_sdes(ssrc: int, cname: str) -> bytes: + """Build an RTCP SDES with one CNAME chunk.""" + name = cname.encode()[:255] + chunk = struct.pack(">IBB", ssrc, 1, len(name)) + name + b"\x00" + chunk += bytes(-len(chunk) % 4) + header = struct.pack(">BBH", RTP_VERSION << 6 | 1, RTCP_SDES, len(chunk) // 4) + return header + chunk + + +def build_bye(ssrcs: Iterable[int]) -> bytes: + """Build an RTCP BYE for the given sources.""" + sources = list(ssrcs) + header = struct.pack( + ">BBH", RTP_VERSION << 6 | len(sources), RTCP_BYE, len(sources) + ) + return header + b"".join(struct.pack(">I", ssrc) for ssrc in sources) + + +def build_pli(sender_ssrc: int, media_ssrc: int) -> bytes: + """Build an RTCP PLI.""" + return struct.pack( + ">BBHII", RTP_VERSION << 6 | FMT_PLI, RTCP_PSFB, 2, sender_ssrc, media_ssrc + ) + + +def build_nack(sender_ssrc: int, media_ssrc: int, lost: Iterable[int]) -> bytes: + """Build an RTCP generic NACK with one FCI per lost packet.""" + fci = b"".join(struct.pack(">HH", seq & 0xFFFF, 0) for seq in lost) + header = struct.pack( + ">BBHII", + RTP_VERSION << 6 | FMT_NACK, + RTCP_RTPFB, + 2 + len(fci) // 4, + sender_ssrc, + media_ssrc, + ) + return header + fci + + +class OutboundStream: + """SSRC, sequence and timestamp rewriting plus SR and NACK state.""" + + def __init__(self, payload_type: int, clock_rate: int) -> None: + self.ssrc = secrets.randbits(32) + self.payload_type = payload_type + self.clock_rate = clock_rate + self.sequence = secrets.randbits(16) + self.packets = 0 + self.octets = 0 + self._out_base = secrets.randbits(32) + self._in_base: int | None = None + self._in_ssrc: int | None = None + self._last_out: int | None = None + self._last_wall = 0.0 + self.history: OrderedDict[int, bytes] = OrderedDict() + + def rebase(self) -> None: + """Continue the timeline from the next input, e.g. of another source.""" + self._in_base = None + + def timestamp(self, ssrc: int, timestamp: int) -> int: + """Map an input timestamp onto this stream's timeline.""" + now = time.monotonic() + if ssrc != self._in_ssrc or self._in_base is None: + # A new or restarted source continues where the old one ended. + if self._last_out is not None: + elapsed = max(1, round((now - self._last_wall) * self.clock_rate)) + self._out_base = self._last_out + elapsed + self._in_ssrc = ssrc + self._in_base = timestamp + out = (timestamp - self._in_base + self._out_base) & 0xFFFFFFFF + self._last_out = out + self._last_wall = now + return out + + def build(self, timestamp: int, marker: bool, payload: bytes) -> bytes: + """Build the next outgoing RTP packet and remember it for NACK.""" + packet = build_rtp( + RtpPacket( + self.payload_type, self.sequence, timestamp, self.ssrc, marker, payload + ) + ) + self.history[self.sequence] = packet + if len(self.history) > RETRANSMIT_HISTORY: + self.history.popitem(last=False) + self.sequence = (self.sequence + 1) & 0xFFFF + self.packets += 1 + self.octets += len(payload) + return packet + + def sender_report(self) -> bytes | None: + """Build an SR, extrapolating the RTP time to now.""" + if self._last_out is None: + return None + elapsed = time.monotonic() - self._last_wall + rtp_now = self._last_out + round(elapsed * self.clock_rate) + return build_sender_report( + self.ssrc, ntp_time(), rtp_now, self.packets, self.octets + ) diff --git a/pyhap/media/source.py b/pyhap/media/source.py new file mode 100644 index 00000000..a2d202f8 --- /dev/null +++ b/pyhap/media/source.py @@ -0,0 +1,293 @@ +"""The RTP input of media sessions: one media source shared by many sessions. + +A :class:`MediaSource` binds two loopback UDP ports. The integrator points one +media pull of a camera channel at them (for example ffmpeg with ``-c:v copy +-bsf:v dump_extra -f rtp`` for H.265 and ``-c:a libopus -ac 1 -frame_duration +20 -f rtp`` for Opus). Any number of sessions attach to the source: the H.265 +access units are reassembled once per source, SFrame, RTP rewriting and SRTP +then run per session. + +Input is only reassembled while an attached session is connected; until then +the source only notes when the last IRAP picture started. RTCP that ffmpeg +sends to an RTP port is dropped, and a new input SSRC (a restarted source) +starts a fresh access unit. + +The source measures what its input carries: the video bit rate +(:attr:`MediaSource.video_bitrate`, which the sender's pacer follows) and the +interval between IRAP pictures (:attr:`MediaSource.gop`). Each port takes +datagrams from one sender address at a time; another sender is only accepted +once the first one was silent for :data:`INPUT_PEER_TIMEOUT` seconds, as a +restarted media pull is. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +import logging +import socket +import time +from typing import Protocol + +from . import rtp +from .hevc import AccessUnit, HevcDepacketizer, payload_starts_irap + +logger = logging.getLogger(__name__) + +INPUT_SOCKET_BUFFER = 4 * 1024 * 1024 +INPUT_PEER_TIMEOUT = 1.0 +# The bit rate is measured over windows of this many seconds and smoothed. +RATE_WINDOW = 1.0 +RATE_SMOOTHING = 0.5 +GOP_SMOOTHING = 0.25 +# IRAP intervals above this are not taken as the GOP of the input. +MAX_GOP = 60.0 + + +class MediaConsumer(Protocol): + """What a source needs from an attached session.""" + + @property + def wants_media(self) -> bool: + """Whether the session sends media now, so input must be reassembled.""" + + def on_source_video(self, unit: AccessUnit, ssrc: int) -> None: + """A complete access unit of the input SSRC.""" + + def on_source_audio(self, packet: rtp.RtpPacket) -> None: + """One Opus RTP packet.""" + + def on_source_closed(self, reason: str) -> None: + """The source ended; the session should end too.""" + + +class _InputProtocol(asyncio.DatagramProtocol): + """Hands datagrams of one sender address at a time to the callback.""" + + def __init__(self, callback: Callable[[bytes], None]) -> None: + self._callback = callback + self._peer: tuple[str | object, ...] | None = None + self._last = 0.0 + + def datagram_received(self, data: bytes, addr: tuple[str | object, ...]) -> None: + now = time.monotonic() + if addr != self._peer: + if self._peer is not None and now - self._last < INPUT_PEER_TIMEOUT: + return + logger.debug("Media input from %s", addr) + self._peer = addr + self._last = now + try: + self._callback(data) + except Exception: + logger.exception("Error handling media input") + + +def _input_socket(host: str) -> socket.socket: + family = socket.AF_INET6 if ":" in host else socket.AF_INET + sock = socket.socket(family, socket.SOCK_DGRAM) + try: + # A keyframe arrives as a burst; the default buffer drops part of it. + with contextlib.suppress(OSError): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, INPUT_SOCKET_BUFFER) + logger.debug( + "Media input buffer: %d bytes (asked for %d)", + sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF), + INPUT_SOCKET_BUFFER, + ) + sock.bind((host, 0)) + sock.setblocking(False) + except BaseException: + sock.close() + raise + return sock + + +class MediaSource: + """Plain RTP input on two UDP ports, fanned out to attached sessions.""" + + def __init__(self, host: str = "127.0.0.1", audio: bool = True) -> None: + """Configure the source; :meth:`start` binds the ports.""" + self.host = host + self.audio = audio + self.on_closed: Callable[[str], None] | None = None + self.last_video_input = 0.0 + self.video_bitrate = 0.0 + self.gop: float | None = None + self._last_keyframe: float | None = None + self._keyframe_timestamp: int | None = None + self._rate_start = 0.0 + self._rate_bytes = 0 + self._consumers: list[MediaConsumer] = [] + self._depacketizer = HevcDepacketizer() + self._video_ssrc: int | None = None + self._active = False + self._sockets: list[asyncio.DatagramTransport] = [] + self._ports = (0, 0) + self._started = False + self._closed = False + + @property + def rtp_input(self) -> tuple[str, int, int]: + """``(host, video_port, audio_port)``; the audio port is 0 without audio.""" + return self.host, self._ports[0], self._ports[1] + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self._ports[1] != 0 + + @property + def closed(self) -> bool: + """Whether the source was closed.""" + return self._closed + + @property + def session_count(self) -> int: + """The number of attached sessions.""" + return len(self._consumers) + + def _measure(self, packet: rtp.RtpPacket, now: float) -> None: + """Update the input bit rate and the IRAP interval.""" + self._rate_bytes += len(packet.payload) + elapsed = now - self._rate_start + if elapsed >= RATE_WINDOW: + rate = self._rate_bytes * 8 / elapsed if self._rate_start else 0.0 + self.video_bitrate = ( + rate + if not self.video_bitrate + else self.video_bitrate + RATE_SMOOTHING * (rate - self.video_bitrate) + ) + self._rate_start, self._rate_bytes = now, 0 + if not payload_starts_irap(packet.payload): + return + self._last_keyframe = now + previous, self._keyframe_timestamp = self._keyframe_timestamp, packet.timestamp + if previous is None or previous == packet.timestamp: + return + interval = ((packet.timestamp - previous) & 0xFFFFFFFF) / rtp.VIDEO_CLOCK_RATE + if interval > MAX_GOP: + return + self.gop = ( + interval + if self.gop is None + else self.gop + GOP_SMOOTHING * (interval - self.gop) + ) + + def keyframe_age(self) -> float | None: + """Seconds since the last IRAP picture started, None if none yet.""" + if self._last_keyframe is None: + return None + return time.monotonic() - self._last_keyframe + + async def start(self) -> tuple[str, int, int]: + """Bind the input ports and return :attr:`rtp_input`.""" + if self._closed: + raise RuntimeError("Media source is closed") + if self._started: + return self.rtp_input + self._started = True + try: + video = await self._bind(self._on_video_input) + audio = await self._bind(self._on_audio_input) if self.audio else 0 + except BaseException: + self._close_sockets() + raise + if self._closed: + self._close_sockets() + raise RuntimeError("Media source is closed") + self._ports = (video, audio) + return self.rtp_input + + async def _bind(self, callback: Callable[[bytes], None]) -> int: + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint( + lambda: _InputProtocol(callback), sock=_input_socket(self.host) + ) + self._sockets.append(transport) + port: int = transport.get_extra_info("sockname")[1] + return port + + def attach(self, consumer: MediaConsumer) -> None: + """Start delivering media to a session.""" + if self._closed: + raise RuntimeError("Media source is closed") + if consumer not in self._consumers: + self._consumers.append(consumer) + + def detach(self, consumer: MediaConsumer) -> None: + """Stop delivering media to a session.""" + with contextlib.suppress(ValueError): + self._consumers.remove(consumer) + + def close(self, reason: str = "closed") -> None: + """Release the ports and end every attached session with ``reason``.""" + if self._closed: + return + self._closed = True + self._close_sockets() + consumers, self._consumers = self._consumers, [] + for consumer in consumers: + try: + consumer.on_source_closed(reason) + except Exception: + logger.exception("Error ending a session of a media source") + if self.on_closed: + try: + self.on_closed(reason) + except Exception: + logger.exception("Error in media source close callback") + + def _close_sockets(self) -> None: + for sock in self._sockets: + sock.close() + self._sockets.clear() + + def _receivers(self) -> list[MediaConsumer]: + return [c for c in self._consumers if c.wants_media] + + def _on_video_input(self, data: bytes) -> None: + try: + packet = rtp.parse_rtp(data) + except ValueError: + return + if packet.is_rtcp: + return + now = time.monotonic() + self.last_video_input = now + if packet.ssrc != self._video_ssrc: + self._video_ssrc = packet.ssrc + self._keyframe_timestamp = None + self._depacketizer.reset() + self._measure(packet, now) + receivers = self._receivers() + if not receivers: + self._active = False + return + if not self._active: + self._active = True + self._depacketizer.restart() + unit = self._depacketizer.push( + packet.sequence, packet.timestamp, packet.marker, packet.payload + ) + if unit is None: + return + for receiver in receivers: + try: + receiver.on_source_video(unit, packet.ssrc) + except Exception: + logger.exception("Error sending video to a session") + + def _on_audio_input(self, data: bytes) -> None: + try: + packet = rtp.parse_rtp(data) + except ValueError: + return + if packet.is_rtcp or not packet.payload: + return + for receiver in self._receivers(): + try: + receiver.on_source_audio(packet) + except Exception: + logger.exception("Error sending audio to a session") diff --git a/pyhap/media/worker.py b/pyhap/media/worker.py new file mode 100644 index 00000000..f9453681 --- /dev/null +++ b/pyhap/media/worker.py @@ -0,0 +1,655 @@ +"""Run media sources and senders in a worker process. + +SRTP and pacing cost CPU for every packet of every session. In an +application that also runs other work on its event loop (Home Assistant runs +HAP signalling on its main loop) the media path therefore runs in a separate +process. Only the signalling stays on the caller's loop: the proxies here +have the async API of :class:`~pyhap.media.multi_tier.MultiTierSender` and +:class:`~pyhap.media.source.MediaSource`, and their events are called on the +caller's loop. + +One :class:`MediaWorker` is one process with one asyncio loop, shared by +all cameras of the application. A process per camera would cost a Python +interpreter and the crypto imports for each camera, while one loop easily +carries several sessions; an integrator that wants isolation or more cores +can still create several workers. The process starts on first use (or with +:meth:`MediaWorker.start`). When it exits unexpectedly, every session +and source of it ends with a reason, and the next use starts a new one. + +With ``MediaWorker(in_thread=True)`` the same worker runs in a thread of the +caller's process instead, with its own event loop and the same protocol over +a socket pair. That costs no second interpreter and keeps one process, but +the media path shares the GIL with the application. + +The worker talks over its stdin and stdout pipes in length-prefixed frames: +a 4-byte JSON length, a 4-byte binary length, a JSON object and raw bytes. +Nothing is pickled. The worker moves its own stdout aside +so stray prints cannot corrupt the stream, and forwards the records of its +``pyhap`` loggers to the parent's loggers, at the parent's +levels. + +Fan-out: register a source per camera channel with +:meth:`MediaWorker.open_source`, point the media pull at its +``rtp_input`` and start each sender of that channel with it. +The source reassembles H.265 once; SRTP runs per session. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +import itertools +import logging +import os +import socket +import struct +import sys +import threading +from typing import Any, cast + +import orjson + +from . import MediaError + +logger = logging.getLogger(__name__) + +HEADER = struct.Struct(">II") +MAX_MESSAGE = 4 * 1024 * 1024 +DEFAULT_START_TIMEOUT = 15.0 +# Requests run inside HAP writes, whose hooks get at most 9 s; one second less +# than the shortest sensible hook timeout leaves time to answer the write. +DEFAULT_REQUEST_TIMEOUT = 5.0 +STOP_TIMEOUT = 5.0 +WORKER_MODULE = "pyhap.media._worker" +# The loggers the worker forwards, each at the parent's effective level of +# the logger named here. +FORWARDED_LOGGERS = {"pyhap": "pyhap.media"} +THREAD_NAME = "pyhap-media-worker" + + +class MediaWorkerError(MediaError): + """The media worker failed, exited or refused a request.""" + + +def encode_message(message: dict[str, Any], blob: bytes = b"") -> bytes: + """Frame one message and its binary attachment.""" + data = orjson.dumps(message) + if len(data) + len(blob) > MAX_MESSAGE: + raise ValueError("Media worker message too large") + return HEADER.pack(len(data), len(blob)) + data + blob + + +async def read_message(reader: asyncio.StreamReader) -> tuple[dict[str, Any], bytes]: + """Read one framed message; ``asyncio.IncompleteReadError`` at EOF.""" + size, blob_size = HEADER.unpack(await reader.readexactly(HEADER.size)) + if size + blob_size > MAX_MESSAGE: + raise ValueError("Media worker message too large") + message = orjson.loads(await reader.readexactly(size)) + if not isinstance(message, dict): + raise ValueError("Media worker message is not an object") + blob = await reader.readexactly(blob_size) if blob_size else b"" + return message, blob + + +def _call(callback: Callable[..., None] | None, *args: Any) -> None: + if callback is None: + return + try: + callback(*args) + except Exception: + logger.exception("Error in media worker callback") + + +class RemoteMediaSource: + """A :class:`~pyhap.media.source.MediaSource` in the worker process. + + ``on_closed(reason)`` fires when the worker exits. + """ + + def __init__(self, worker: MediaWorker, source_id: int, generation: int) -> None: + """Use :meth:`MediaWorker.open_source`.""" + self.on_closed: Callable[[str], None] | None = None + self.id = source_id + self.rtp_input: tuple[str, int, int] = ("", 0, 0) + self._worker = worker + self._generation = generation + self._closed = False + + @property + def closed(self) -> bool: + """Whether the source was closed or its worker exited.""" + return self._closed + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self.rtp_input[2] != 0 + + async def close(self, reason: str = "closed") -> None: + """Release the ports and end every session on this source.""" + if self._closed: + return + self._closed = True + self._worker._sources.pop(self.id, None) + with contextlib.suppress(MediaWorkerError): + await self._worker._request( + "close_source", self._generation, source=self.id, reason=reason + ) + + def _ended(self, reason: str) -> None: + if self._closed: + return + self._closed = True + _call(self.on_closed, reason) + + +class RemoteMultiTierSender: + """A :class:`~pyhap.media.multi_tier.MultiTierSender` in the worker process. + + Same methods and callbacks; ``on_closed(reason)`` also fires when the + worker exits. + """ + + def __init__( + self, worker: MediaWorker, session_id: int, config: dict[str, Any] + ) -> None: + """Use :meth:`MediaWorker.create_multi_tier_sender`.""" + self.on_closed: Callable[[str], None] | None = None + self.on_keyframe_request: ( + Callable[[float | None, float | None], None] | None + ) = None + self.on_rtcp: Callable[[], None] | None = None + self.id = session_id + self.ports: tuple[int, int] = (0, 0) + self._worker = worker + self._config = config + self._generation: int | None = None + self._state = "new" + + @property + def started(self) -> bool: + """Whether media flows.""" + return self._state == "started" + + @property + def closed(self) -> bool: + """Whether the session ended.""" + return self._state == "closed" + + async def bind(self) -> tuple[int, int]: + """Create the session in the worker and bind the accessory's ports.""" + if self._state != "new": + raise MediaWorkerError("Multi-tier session already bound") + self._state = "binding" + try: + generation = await self._worker.start() + if self._state == "closed": + raise MediaWorkerError("Multi-tier session closed") + self._generation = generation + result = await self._worker._request( + "mt_bind", generation, session=self.id, config=self._config + ) + except BaseException: + await self.close() + raise + if self._state == "closed": + with contextlib.suppress(MediaWorkerError): + await self._worker._request("close", generation, session=self.id) + raise MediaWorkerError("Multi-tier session closed") + self._worker._handles[self.id] = self + self._state = "bound" + self.ports = (int(result["ports"][0]), int(result["ports"][1])) + return self.ports + + async def start(self, source: RemoteMediaSource, audio: bool = True) -> None: + """Send ``source`` to the controller, with its audio if ``audio``.""" + await self._session_request("mt_start", source, audio=audio) + if self._state == "bound": + self._state = "started" + + async def switch(self, source: RemoteMediaSource) -> None: + """Continue the stream from another source of the same worker.""" + await self._session_request("mt_switch", source) + + async def close(self) -> None: + """End the session without calling ``on_closed``.""" + if self._state == "closed": + return + generation = self._generation + self._ended(None) + if generation is not None: + with contextlib.suppress(MediaWorkerError): + await self._worker._request("close", generation, session=self.id) + + async def _session_request( + self, op: str, source: RemoteMediaSource, **fields: Any + ) -> Any: + if self._state not in ("bound", "started") or self._generation is None: + raise MediaWorkerError("No multi-tier session in the worker") + if source._worker is not self._worker or source._generation != self._generation: + raise MediaWorkerError("The source belongs to another worker") + if source.closed: + raise MediaWorkerError("Media source is closed") + return await self._worker._request( + op, self._generation, session=self.id, source=source.id, **fields + ) + + def _event(self, event: str, message: dict[str, Any], blob: bytes) -> None: + if self._state == "closed": + return + if event == "rtcp": + _call(self.on_rtcp) + elif event == "keyframe_request": + _call(self.on_keyframe_request, message.get("age"), message.get("gop")) + elif event == "closed": + self._ended(str(message.get("reason", "closed"))) + + def _ended(self, reason: str | None) -> None: + if self._state == "closed": + return + self._state = "closed" + self._worker._handles.pop(self.id, None) + if reason is not None: + _call(self.on_closed, reason) + + +class _ProcessRunner: + """The worker as a child process, talking over its stdin and stdout.""" + + def __init__(self, process: asyncio.subprocess.Process) -> None: + # Both are set: the process is started with pipes for them. + self.reader = cast(asyncio.StreamReader, process.stdout) + self.writer = cast(asyncio.StreamWriter, process.stdin) + self._process = process + + @property + def running(self) -> bool: + return self._process.returncode is None + + @property + def pid(self) -> int | None: + return self._process.pid + + def kill(self) -> None: + with contextlib.suppress(ProcessLookupError): + self._process.kill() + + async def wait(self) -> int: + return await self._process.wait() + + +class _ThreadRunner: + """The worker as a thread with its own loop, talking over a socket pair.""" + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + done: asyncio.Future[int], + ) -> None: + self.reader = reader + self.writer = writer + self._done = done + + @property + def running(self) -> bool: + return not self._done.done() + + @property + def pid(self) -> int | None: + return None + + def kill(self) -> None: + # A thread cannot be killed: drop the connection, which ends a worker + # that still runs its loop, and count the worker as gone either way. + self.writer.close() + if not self._done.done(): + self._done.set_result(-1) + + async def wait(self) -> int: + return await asyncio.shield(self._done) + + +async def _start_thread() -> _ThreadRunner: + from . import _worker # noqa: PLC0415 - the worker module imports this one + + loop = asyncio.get_running_loop() + done: asyncio.Future[int] = loop.create_future() + parent, child = socket.socketpair() + + def finished(code: int) -> None: + if not done.done(): + done.set_result(code) + + def run() -> None: + code = 0 + try: + asyncio.run(_worker.serve_socket(child)) + except Exception: + logger.exception("Media worker thread failed") + code = 1 + finally: + child.close() + # The parent loop may be gone when the application shuts down. + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(finished, code) + + try: + reader, writer = await asyncio.open_connection(sock=parent) + except BaseException: + parent.close() + child.close() + raise + threading.Thread(target=run, name=THREAD_NAME, daemon=True).start() + return _ThreadRunner(reader, writer, done) + + +class MediaWorker: + """A worker process or thread running media sources and senders.""" + + def __init__( + self, + *, + in_thread: bool = False, + python: str | None = None, + start_timeout: float = DEFAULT_START_TIMEOUT, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + """Configure the worker; it starts on first use. + + ``in_thread`` runs it in a thread of this process instead of a child + process; ``python`` then has no effect. + """ + self.on_exit: Callable[[str], None] | None = None + self._in_thread = in_thread + self._python = python or sys.executable + self._start_timeout = start_timeout + self._request_timeout = request_timeout + self._runner: _ProcessRunner | _ThreadRunner | None = None + self._reader: asyncio.Task[None] | None = None + self._ready: asyncio.Future[None] | None = None + self._lock = asyncio.Lock() + self._generation = 0 + self._stopping = False + self._ids = itertools.count(1) + self._pending: dict[int, asyncio.Future[Any]] = {} + self._handles: dict[int, RemoteMultiTierSender] = {} + self._sources: dict[int, RemoteMediaSource] = {} + + @property + def in_thread(self) -> bool: + """Whether the worker runs in a thread instead of a child process.""" + return self._in_thread + + @property + def running(self) -> bool: + """Whether the worker is up.""" + return self._runner is not None and self._runner.running + + @property + def pid(self) -> int | None: + """The worker's process ID while it runs as a child process.""" + return self._runner.pid if self.running and self._runner else None + + async def start(self) -> int: + """Start the worker unless it runs; return its generation.""" + async with self._lock: + if self.running and self._ready is not None and self._ready.done(): + return self._generation + await self._spawn() + return self._generation + + async def _spawn(self) -> None: + runner = await (_start_thread() if self._in_thread else self._start_process()) + if self._handles or self._sources or self._pending: + # The previous worker exited and its reader did not get to it yet. + self._exited(self._generation, "media worker exited") + self._generation += 1 + self._stopping = False + self._runner = runner + self._ready = asyncio.get_running_loop().create_future() + self._reader = asyncio.get_running_loop().create_task( + self._read_loop(runner, self._generation) + ) + try: + await asyncio.wait_for(asyncio.shield(self._ready), self._start_timeout) + except BaseException: + await self._kill(runner) + raise + if runner.pid is None: + logger.debug("Media worker started in a thread") + else: + logger.debug("Media worker %s started", runner.pid) + + async def _start_process(self) -> _ProcessRunner: + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + os.path.abspath(path) for path in sys.path if path + ) + levels = [str(level) for level in _log_levels()] + # -P and a fixed working directory: the worker uses exactly the + # parent's import path. + process = await asyncio.create_subprocess_exec( + self._python, + "-P", + "-m", + WORKER_MODULE, + *levels, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + env=env, + cwd=os.path.abspath(os.sep), + ) + return _ProcessRunner(process) + + async def update_log_levels(self) -> None: + """Apply the parent's current log levels in the worker. + + The worker starts with the levels of that moment; call this after + changing them, e.g. when an integration turns on debug logging. A + worker thread shares the loggers, so there is nothing to apply. + """ + if self._in_thread: + return + await self._request("log_levels", self._generation, levels=_log_levels()) + + async def open_source( + self, host: str = "127.0.0.1", audio: bool = True + ) -> RemoteMediaSource: + """Bind a media source in the worker; see ``rtp_input``.""" + generation = await self.start() + source = RemoteMediaSource(self, next(self._ids), generation) + self._sources[source.id] = source + try: + result = await self._request( + "open_source", generation, source=source.id, host=host, audio=audio + ) + except BaseException: + self._sources.pop(source.id, None) + source._closed = True + raise + source.rtp_input = tuple(result["rtp_input"]) # type: ignore[assignment] + return source + + def create_multi_tier_sender( + self, + *, + address: str, + video_port: int, + audio_port: int, + video_key: bytes, + audio_key: bytes, + video_ssrc: int, + audio_ssrc: int, + **options: Any, + ) -> RemoteMultiTierSender: + """Create a multi-tier sender proxy; :meth:`RemoteMultiTierSender.bind`. + + The arguments are those of + :class:`~pyhap.media.multi_tier.MultiTierSender`. + """ + unknown = set(options) - _MULTI_TIER_OPTIONS + if unknown: + raise TypeError(f"Unknown multi-tier sender options: {sorted(unknown)}") + config = { + "address": address, + "video_port": video_port, + "audio_port": audio_port, + "video_key": bytes(video_key).hex(), + "audio_key": bytes(audio_key).hex(), + "video_ssrc": video_ssrc, + "audio_ssrc": audio_ssrc, + **options, + } + return RemoteMultiTierSender(self, next(self._ids), config) + + async def close(self) -> None: + """End every session and stop the worker.""" + async with self._lock: + runner = self._runner + if runner is None or not runner.running: + return + self._stopping = True + with contextlib.suppress(MediaWorkerError, asyncio.TimeoutError): + await asyncio.wait_for( + self._request("shutdown", self._generation), STOP_TIMEOUT + ) + runner.writer.close() + try: + await asyncio.wait_for(runner.wait(), STOP_TIMEOUT) + except TimeoutError: + await self._kill(runner) + if self._reader is not None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._reader + + async def _kill(self, runner: _ProcessRunner | _ThreadRunner) -> None: + runner.kill() + await runner.wait() + + async def _request(self, op: str, generation: int, **fields: Any) -> Any: + runner = self._runner + if generation != self._generation or runner is None or not runner.running: + raise MediaWorkerError("Media worker is not running") + request_id = next(self._ids) + try: + data = encode_message({"id": request_id, "op": op, **fields}) + except (ValueError, TypeError) as err: + raise MediaWorkerError(f"Request {op} not sent: {err}") from err + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = future + try: + return await asyncio.wait_for( + self._exchange(runner.writer, data, future), self._request_timeout + ) + except (ConnectionError, BrokenPipeError) as err: + raise MediaWorkerError("Media worker is not running") from err + except TimeoutError as err: + # A worker that stops answering is stuck: end it, so its sessions + # end and the next use starts a new one. + logger.warning("Media worker did not answer %s, stopping it", op) + runner.kill() + raise MediaWorkerError(f"Media worker did not answer {op}") from err + finally: + self._pending.pop(request_id, None) + + @staticmethod + async def _exchange( + writer: asyncio.StreamWriter, data: bytes, future: asyncio.Future[Any] + ) -> Any: + """Send a request and wait for its answer, under one deadline.""" + writer.write(data) + await writer.drain() + return await future + + async def _read_loop( + self, runner: _ProcessRunner | _ThreadRunner, generation: int + ) -> None: + reason = "media worker exited" + try: + while True: + message, blob = await read_message(runner.reader) + self._dispatch(message, blob) + except (asyncio.IncompleteReadError, ConnectionError): + pass + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - a broken stream ends the worker + logger.warning("Media worker sent invalid data: %r", err) + reason = "media worker protocol error" + runner.kill() + code = await runner.wait() + if self._stopping: + reason = "media worker stopped" + self._exited(generation, f"{reason} (code {code})") + + def _dispatch(self, message: dict[str, Any], blob: bytes) -> None: + if "id" in message: + future = self._pending.get(int(message["id"])) + if future is None or future.done(): + return + if "error" in message: + future.set_exception(MediaWorkerError(str(message["error"]))) + else: + future.set_result(message.get("result")) + return + event = message.get("event") + if event == "ready": + if self._ready is not None and not self._ready.done(): + self._ready.set_result(None) + elif event == "log": + logging.getLogger(str(message.get("name", __name__))).log( + int(message.get("level", logging.INFO)), + "[media worker] %s", + message.get("message", ""), + ) + elif event == "source_closed": + source = self._sources.pop(int(message["source"]), None) + if source is not None: + source._ended(str(message.get("reason", "closed"))) + elif "session" in message: + sender = self._handles.get(int(message["session"])) + if sender is not None: + sender._event(str(event), message, blob) + + def _exited(self, generation: int, reason: str) -> None: + if generation != self._generation: + return + logger.debug("Media worker ended: %s", reason) + if self._ready is not None and not self._ready.done(): + self._ready.set_exception(MediaWorkerError(reason)) + self._ready.exception() + for future in self._pending.values(): + if not future.done(): + future.set_exception(MediaWorkerError(reason)) + # Retrieved here so a caller that gave up logs no warning. + future.exception() + senders, self._handles = self._handles, {} + sources, self._sources = self._sources, {} + for sender in senders.values(): + sender._ended(reason) + for source in sources.values(): + source._ended(reason) + _call(self.on_exit, reason) + + +_MULTI_TIER_OPTIONS = { + "video_payload_type", + "audio_payload_type", + "local_video_port", + "local_audio_port", + "is_ipv6", + "max_payload", + "idle_timeout", + "keyframe_request_interval", + "rtcp_notify_interval", +} + + +def _log_levels() -> list[int]: + """The parent's effective level of each forwarded logger.""" + return [ + logging.getLogger(name).getEffectiveLevel() + for name in FORWARDED_LOGGERS.values() + ] diff --git a/pyproject.toml b/pyproject.toml index 841aec15..17ebd85e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "orjson>=3.7.2", "zeroconf>=0.36.2", "h11", + "pylibsrtp>=0.10", ] dynamic = ["version"] @@ -35,9 +36,8 @@ QRCode = [ "base36", "pyqrcode", ] -Talkback = [ - "pylibsrtp", -] +# Kept for existing installs: two-way audio needs nothing extra. +Talkback = [] bench = [ "psutil", "py-spy", diff --git a/requirements_test.txt b/requirements_test.txt index c89abf07..53974d6d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ # Test/lint tooling only. Library dependencies (including the optional -# QRCode/Talkback extras) come from the package itself - tox installs it, -# so use e.g. `pip install -e ".[QRCode,Talkback]"` in a dev venv. +# QRCode extra) come from the package itself - tox installs it, so use e.g. +# `pip install -e ".[QRCode]"` in a dev venv. pytest pytest-asyncio pytest-cov diff --git a/tests/media_fakes.py b/tests/media_fakes.py new file mode 100644 index 00000000..dfe4faa8 --- /dev/null +++ b/tests/media_fakes.py @@ -0,0 +1,229 @@ +"""Fakes of the media worker and sessions for the media tests (not a test module). + +The fake worker stands in for both the in-process and the worker media. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterable +import pathlib +import sys +from typing import Any +from uuid import UUID, uuid4 + +from pyhap import hksv3 +from pyhap.camera_webrtc import WebRTCSession +from pyhap.media import MediaError +from pyhap.media.pool import ChannelLease, SourcePool + +FAKE_OFFER = "v=0\r\na=offer\r\n" +FAKE_REOFFER_ANSWER = "v=0\r\na=reoffer-answer\r\n" +SENSOR = hksv3.SensorConfiguration( + UUID("00112233-4455-6677-8899-aabbccddeeff"), + 3840, + 2160, + video_streams=[ + hksv3.VideoStreamCapability(UUID(int=1), 2, 1920, 1080, 30, 1700, 1800), + hksv3.VideoStreamCapability(UUID(int=2), 3, 1280, 720, 30, 768, 800), + hksv3.VideoStreamCapability(UUID(int=3), 4, 640, 360, 15, 180, 190), + ], +) +MEDIUM = hksv3.tiers_from_sensor(SENSOR)[1] +LOW = hksv3.tiers_from_sensor(SENSOR)[2] +AUDIO = hksv3.DEFAULT_AUDIO_TIER + + +def make_session(sframe: bool = True) -> WebRTCSession: + """A WebRTC session on the Medium tier, outside any controller.""" + return WebRTCSession( + id=uuid4(), + sensor=SENSOR, + client_addr=None, + sframe=sframe, + video_tier=MEDIUM, + audio_tier=AUDIO, + ) + + +def write_fake_ffmpeg(directory: pathlib.Path) -> pathlib.Path: + """Write an ffmpeg stand-in: logs its arguments, prints progress, waits. + + It exits at once with 1 while ``exit-now`` exists next to it, and prints + a camera URL with credentials and a token. + """ + script = directory / "ffmpeg" + script.write_text( + f"#!{sys.executable}\n" + "import pathlib, signal, sys, time\n" + "here = pathlib.Path(__file__).parent\n" + "with open(here / 'starts.log', 'a') as log:\n" + " log.write(' '.join(sys.argv[1:]) + '\\n')\n" + "sys.stderr.write('frame=1 fps=0\\rframe=2 fps=0\\nwarning\\n')\n" + "sys.stderr.write('rtsps://user:pw@192.168.1.1:7441/T0k3n?x=1: 404\\n')\n" + "sys.stderr.flush()\n" + "if (here / 'exit-now').exists():\n" + " sys.exit(1)\n" + "signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n" + "time.sleep(60)\n" + ) + script.chmod(0o755) + return script + + +class FakeSource: + """Stands in for a MediaSource or RemoteMediaSource.""" + + def __init__(self, port: int, audio: bool) -> None: + """Pretend to be bound.""" + self.rtp_input = ("127.0.0.1", port, port + 1 if audio else 0) + self.on_closed: Callable[[str], None] | None = None + self.close_reasons: list[str] = [] + + @property + def closed(self) -> bool: + """Whether the source was closed.""" + return bool(self.close_reasons) + + @property + def has_audio(self) -> bool: + """Whether the source takes audio.""" + return self.rtp_input[2] != 0 + + def keyframe_age(self) -> float | None: + """No IRAP picture seen yet.""" + return None + + async def close(self, reason: str = "closed") -> None: + """Record the close.""" + self.close_reasons.append(reason) + + def end(self, reason: str) -> None: + """End on its own, as when the worker exits.""" + assert self.on_closed is not None + self.on_closed(reason) + + +class FakeSender: + """Stands in for a WebRTCSender, recording every call.""" + + def __init__(self, **options: Any) -> None: + """Keep the options the delegate created it with.""" + self.options = options + self.on_closed: Callable[[str], None] | None = None + self.on_connected: Callable[[], None] | None = None + self.on_keyframe_request: ( + Callable[[float | None, float | None], None] | None + ) = None + self.on_audio: Callable[[bytes, int], None] | None = None + self.calls: list[tuple[Any, ...]] = [] + self.error: MediaError | None = None + + def _call(self, *call: Any) -> None: + self.calls.append(call) + if self.error is not None: + raise self.error + + async def create_offer(self) -> str: + """Return a fixed offer.""" + self._call("offer") + return FAKE_OFFER + + async def apply_answer(self, sdp: str, candidates: Iterable[Any]) -> None: + """Record the answer.""" + self._call("answer", sdp, list(candidates)) + + async def reoffer(self, sdp: str) -> str: + """Return a fixed answer.""" + self._call("reoffer", sdp) + return FAKE_REOFFER_ANSWER + + async def update_receive_keys( + self, add: Iterable[Any] = (), remove: Iterable[int] = () + ) -> None: + """Record the keys.""" + self._call("keys", list(add), list(remove)) + + async def close(self) -> None: + """Record the close; never fails.""" + self.calls.append(("close",)) + + +class FakeWorker: + """Stands in for a MediaWorker.""" + + pid = 4242 + + def __init__(self) -> None: + """Start without sources or senders.""" + self.sources: list[FakeSource] = [] + self.senders: list[FakeSender] = [] + self.end_on_open: str | None = None + + async def open_source(self, host: str = "127.0.0.1", audio: bool = True) -> Any: + """Open a fake source on new ports, already ended if ``end_on_open``.""" + source = FakeSource(5000 + 2 * len(self.sources), audio) + if self.end_on_open is not None: + source.close_reasons.append(self.end_on_open) + self.sources.append(source) + return source + + def create_sender(self, **options: Any) -> Any: + """Create a fake sender.""" + sender = FakeSender(**options) + self.senders.append(sender) + return sender + + +class FakePull: + """Stands in for an ffmpeg pull.""" + + def __init__(self) -> None: + """Not running yet.""" + self.started: list[tuple[str, int, int]] = [] + self.stopped = 0 + self.keyframes: list[tuple[float | None, float | None]] = [] + self.error: Exception | None = None + self.gate: asyncio.Event | None = None + self.waiting = False + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Record the start, or fail; wait for ``gate`` first if set.""" + if self.gate is not None: + self.waiting = True + await self.gate.wait() + if self.error is not None: + raise self.error + self.started.append(rtp_input) + + async def stop(self) -> None: + """Record the stop.""" + self.stopped += 1 + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Record the request.""" + self.keyframes.append((age, gop)) + + +class FakeProvider: + """A source provider with a channel per tier quality and fake pulls.""" + + def __init__(self, pool: SourcePool) -> None: + """Lease from ``pool``.""" + self.pool = pool + self.pulls: list[FakePull] = [] + + async def __call__( + self, + session: WebRTCSession, + video_tier: hksv3.VideoStreamTier, + audio_tier: hksv3.AudioStreamTier, + ) -> ChannelLease: + """Lease the channel of the tier's quality.""" + name = hksv3.VideoQuality(video_tier.quality).name.lower() + return await self.pool.lease(video_tier.quality, self._pull, name=name) + + def _pull(self) -> FakePull: + pull = FakePull() + self.pulls.append(pull) + return pull diff --git a/tests/media_hap.py b/tests/media_hap.py new file mode 100644 index 00000000..8cb97b4b --- /dev/null +++ b/tests/media_hap.py @@ -0,0 +1,63 @@ +"""A synthetic camera channel for the HAP media tests (not a test module).""" + +from __future__ import annotations + +import asyncio +import contextlib + +from pyhap.accessory import Bridge +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import Camera + +from .media_peer import KEYFRAME, TRAIL, Source + +CLIENT = ("127.0.0.1", 50000) +# A synthetic camera: an IRAP picture every GOP_FRAMES frames. +GOP_FRAMES = 10 +FRAME_INTERVAL = 0.02 + + +class SyntheticPull: + """Plays a camera channel: synthetic H.265 to the source's ports.""" + + def __init__(self) -> None: + """Not running yet.""" + self.starts = 0 + self.stops = 0 + self.keyframe_requests: list[tuple[float | None, float | None]] = [] + self._task: asyncio.Task[None] | None = None + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Send frames until stopped.""" + self.starts += 1 + self._task = asyncio.ensure_future(self._send(Source(rtp_input))) + + async def _send(self, source: Source) -> None: + try: + frame = 0 + while True: + source.send_video(KEYFRAME if frame % GOP_FRAMES == 0 else [TRAIL]) + frame += 1 + await asyncio.sleep(FRAME_INTERVAL) + finally: + source.close() + + async def stop(self) -> None: + """Stop sending.""" + self.stops += 1 + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Record the request; the next GOP brings a keyframe.""" + self.keyframe_requests.append((age, gop)) + + +def bridged(driver: AccessoryDriver, camera: Camera) -> Camera: + """Add the camera to a bridge on the driver.""" + bridge = Bridge(driver, "Bridge") + bridge.add_accessory(camera) + driver.add_accessory(bridge) + return camera diff --git a/tests/media_helpers.py b/tests/media_helpers.py new file mode 100644 index 00000000..7ddbb944 --- /dev/null +++ b/tests/media_helpers.py @@ -0,0 +1,61 @@ +"""Shared helpers for the pyhap.media tests (not a test module).""" + +from __future__ import annotations + +import hashlib +import struct +from typing import Any + +from pyhap.media.hevc import NAL_AP, fragment + + +def seeded_bytes(seed: str, length: int) -> bytes: + """Reproduce the vector generator's SHA-256 counter byte stream.""" + out = b"" + counter = 0 + while len(out) < length: + out += hashlib.sha256(f"{seed}:{counter}".encode()).digest() + counter += 1 + return out[:length] + + +def blob(value: bytes) -> Any: + """Describe bytes the way the generator stores them.""" + if len(value) <= 64: + return value.hex() + return {"len": len(value), "sha256": hashlib.sha256(value).hexdigest()} + + +def scrypted_nal(spec: list[Any]) -> bytes: + """Build a NAL unit from a generator ``[type, size, seed]`` spec.""" + kind, size, seed = spec + return bytes([kind << 1, 1]) + seeded_bytes(seed, size - 2) + + +def scrypted_payloads(unit: list[Any], fu_chunk: int) -> list[bytes]: + """Build the RTP payloads of one generator ``[kind, nal specs]`` entry.""" + kind, specs = unit + nals = [scrypted_nal(spec) for spec in specs] + if kind == "ap": + header = bytes([NAL_AP << 1, 1]) + return [header + b"".join(struct.pack(">H", len(n)) + n for n in nals)] + if kind == "fu": + return fragment(nals[0], fu_chunk + 3) + return nals + + +def scrypted_input(case: dict[str, Any]) -> list[tuple[int, int, bool, bytes]]: + """Rebuild the ``(sequence, timestamp, marker, payload)`` HEVC RTP input.""" + packets: list[tuple[int, int, bool, bytes]] = [] + sequence = int(case["input"][0]["sequence"]) + for index, unit in enumerate(case["access_units"]): + payloads = [ + payload + for spec in unit + for payload in scrypted_payloads(spec, case["fu_chunk"]) + ] + for position, payload in enumerate(payloads): + marker = position == len(payloads) - 1 + packets.append((sequence, 3000 * (index + 1), marker, payload)) + sequence = (sequence + 1) & 0xFFFF + return packets diff --git a/tests/media_peer.py b/tests/media_peer.py new file mode 100644 index 00000000..8aab4c65 --- /dev/null +++ b/tests/media_peer.py @@ -0,0 +1,76 @@ +"""An RTP source playing a camera for the media tests. + +Not a test module. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import socket + +from pyhap.media import rtp +from pyhap.media.hevc import HevcPacketizer + + +def nal(kind: int, size: int, fill: int) -> bytes: + """Build a NAL unit of a type and size.""" + return bytes([kind << 1, 1]) + bytes([fill]) * (size - 2) + + +VPS, SPS, PPS = nal(32, 20, 1), nal(33, 30, 2), nal(34, 6, 3) +IDR = nal(19, 40000, 4) +TRAIL = nal(1, 2500, 5) +LEADING_TRAIL = nal(1, 300, 6) +KEYFRAME = [VPS, SPS, PPS, IDR] + + +class Source: + """Plays the integrator's ffmpeg: plain RTP to the input ports.""" + + def __init__(self, ports: tuple[str, int, int], ssrc: int = 0xAAAA) -> None: + """Open a UDP socket towards the sender's input.""" + self.host, self.video_port, self.audio_port = ports + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.ssrc = ssrc + self.sequence = 5000 + self.timestamp = 1000 + self.audio_sequence = 100 + + def send_video(self, nals: list[bytes]) -> None: + """Send one access unit as RFC 7798 RTP.""" + payloads = HevcPacketizer(1400).packetize(nals) + for index, payload in enumerate(payloads): + packet = rtp.RtpPacket( + 96, + self.sequence, + self.timestamp, + self.ssrc, + index == len(payloads) - 1, + payload, + ) + self.send_raw(rtp.build_rtp(packet)) + self.sequence = (self.sequence + 1) & 0xFFFF + self.timestamp += 3000 + + def send_raw(self, data: bytes) -> None: + """Send a datagram to the video input.""" + self.sock.sendto(data, (self.host, self.video_port)) + + def send_audio(self, opus: bytes) -> None: + """Send one Opus RTP packet.""" + packet = rtp.RtpPacket( + 97, self.audio_sequence, 960 * self.audio_sequence, 0xBBBB, False, opus + ) + self.sock.sendto(rtp.build_rtp(packet), (self.host, self.audio_port)) + self.audio_sequence += 1 + + def close(self) -> None: + """Close the socket.""" + self.sock.close() + + +async def wait_until(predicate: Callable[[], bool]) -> None: + """Poll a predicate on the event loop.""" + while not predicate(): + await asyncio.sleep(0.01) diff --git a/tests/test_camera.py b/tests/test_camera.py index dc3dec9b..d8077b2f 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch from uuid import UUID +import pylibsrtp import pytest from pyhap import camera, tlv @@ -275,8 +276,6 @@ async def test_talkback_audio_is_received_and_dispatched(mock_driver: Any) -> No client would during a live view session) is decrypted, RTP-parsed, and dispatched to ``talkback_audio_received`` with the raw codec payload. """ - pylibsrtp = pytest.importorskip("pylibsrtp") - mock_driver.loop = asyncio.get_running_loop() received = [] diff --git a/tests/test_media_ffmpeg_provider.py b/tests/test_media_ffmpeg_provider.py new file mode 100644 index 00000000..43158560 --- /dev/null +++ b/tests/test_media_ffmpeg_provider.py @@ -0,0 +1,426 @@ +"""Tests for pyhap.media.ffmpeg with a stand-in ffmpeg and a fake worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +import os +from pathlib import Path +import signal +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from pyhap import hksv3 +from pyhap.camera_stream import StreamError +from pyhap.media import ffmpeg as ffmpeg_module +from pyhap.media.ffmpeg import ( + FFmpegInput, + FFmpegPull, + FFmpegSourceProvider, + redact_urls, +) + +from .media_fakes import ( + AUDIO, + LOW, + MEDIUM, + FakeWorker, + make_session, + write_fake_ffmpeg, +) + +pytestmark = pytest.mark.timeout(5) + +TIER_ONE = hksv3.VideoStreamTier(1, 3, 1, 1, 1, 1) +DEFAULTS = ("0:v:0", "0:a:0", True, 2.0) +INPUTS: dict[int | hksv3.VideoQuality | str, list[str] | FFmpegInput] = { + 1: ["-i", "tier-one"], + hksv3.VideoQuality.MEDIUM: ["-i", "medium"], + "low": FFmpegInput(("-i", "low"), video_map="0:1", audio=False, gop=4.0), +} + + +@pytest.fixture +def fake_ffmpeg(tmp_path: Path) -> Path: + """An ffmpeg stand-in.""" + return write_fake_ffmpeg(tmp_path) + + +async def wait_until(predicate: Callable[[], bool]) -> None: + """Poll a predicate on the event loop.""" + while not predicate(): + await asyncio.sleep(0.01) + + +@pytest.mark.parametrize( + ("tier", "expected", "name"), + [ + (TIER_ONE, FFmpegInput(("-i", "tier-one"), *DEFAULTS), "tier 1"), + (MEDIUM, FFmpegInput(("-i", "medium"), *DEFAULTS), "medium"), + (LOW, FFmpegInput(("-i", "low"), "0:1", "0:a:0", False, 4.0), "low"), + ], +) +async def test_ffmpeg_inputs( + tier: hksv3.VideoStreamTier, expected: FFmpegInput, name: str +) -> None: + """Verify an identifier wins over a quality, also given by name.""" + provider = FFmpegSourceProvider(INPUTS) + + assert await provider.input_for(tier) == expected + assert provider.channel_name(tier) == name + + +async def test_ffmpeg_no_input() -> None: + """Verify a tier without an input refuses the offer.""" + provider = FFmpegSourceProvider({"high": ["-i", "high"]}) + + with pytest.raises(StreamError, match="No ffmpeg input"): + await provider.input_for(LOW) + + +async def resolve(tier: hksv3.VideoStreamTier) -> list[str] | None: + """An input resolver with an input for the Medium tier only.""" + return ["-i", f"resolved-{tier.identifier}"] if tier is MEDIUM else None + + +async def test_ffmpeg_input_resolver() -> None: + """Verify an async resolver maps tiers to inputs, None refusing the offer.""" + provider = FFmpegSourceProvider(resolve, video_map="0:2", audio=False, gop=1.0) + + found = await provider.input_for(MEDIUM) + + assert found == FFmpegInput(("-i", "resolved-2"), "0:2", "0:a:0", False, 1.0) + with pytest.raises(StreamError, match="No ffmpeg input"): + await provider.input_for(LOW) + + +def test_ffmpeg_channel_name_of_an_unknown_quality() -> None: + """Verify a tier of an unknown quality is named by its identifier.""" + provider = FFmpegSourceProvider(resolve) + + assert provider.channel_name(hksv3.VideoStreamTier(7, 9, 1, 1, 1, 1)) == "tier 7" + + +def test_ffmpeg_command() -> None: + """Verify ffmpeg copies H.265 and encodes Opus to the source's ports.""" + provider = FFmpegSourceProvider(INPUTS, ffmpeg="/bin/ffmpeg", stats_period=10) + found = FFmpegInput(("-i", "cam"), "0:v:1", "0:a:2") + + command = provider.command(found, ("127.0.0.1", 6000, 6002), 24000) + + assert command == [ + *("/bin/ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning"), + *("-stats", "-stats_period", "10", "-i", "cam"), + *("-map", "0:v:1", "-c:v", "copy", "-bsf:v", "dump_extra"), + *("-payload_type", "99", "-rtpflags", "skip_rtcp"), + *("-f", "rtp", "rtp://127.0.0.1:6000"), + *("-map", "0:a:2", "-c:a", "libopus", "-ac", "1", "-ar", "48000"), + *("-b:a", "24000", "-application", "lowdelay", "-frame_duration", "20"), + *("-payload_type", "110", "-rtpflags", "skip_rtcp"), + *("-f", "rtp", "rtp://127.0.0.1:6002"), + ] + + +def test_ffmpeg_command_packet_time() -> None: + """Verify the Opus frame duration follows the audio tier's packet time.""" + provider = FFmpegSourceProvider(INPUTS) + + command = provider.command( + FFmpegInput(("-i", "cam")), ("127.0.0.1", 6000, 6002), 32000, packet_time=40 + ) + + assert command[command.index("-frame_duration") + 1] == "40" + assert command[command.index("-b:a") + 1] == "32000" + + +async def test_ffmpeg_provider_without_audio_tier(fake_ffmpeg: Path) -> None: + """Verify a multi-tier session without audio leases with the default tier.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + + lease = await provider(make_session(), LOW, None) + + assert lease.channel == "low" + await lease.release() + + +def test_ffmpeg_command_without_audio() -> None: + """Verify a source without audio gets video only, IPv6 in brackets.""" + provider = FFmpegSourceProvider(INPUTS) + + command = provider.command(FFmpegInput(("-i", "cam")), ("::1", 6000, 0), 24000) + + assert command[-1] == "rtp://[::1]:6000" + assert command[command.index("-map") + 1] == "0:v:0" + assert "-nostats" in command + assert "libopus" not in command + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ( + "rtsps://user:pw@192.168.1.1:7441/T0k3n?enableSrtp: 404 Not Found", + "rtsps://192.168.1.1:7441/ 404 Not Found", + ), + ( + "Input #0, rtsp, from 'rtsp://[::1]:554/a/b':", + "Input #0, rtsp, from 'rtsp://[::1]:554/':", + ), + ("http://cam.local:80 timed out", "http://cam.local:80 timed out"), + ("frame= 25 fps=0.0 q=-1.0", "frame= 25 fps=0.0 q=-1.0"), + ( + "rtsp://my user:p w@10.0.0.2:554/live failed", + "rtsp://10.0.0.2:554/ failed", + ), + ( + """rtsp://user:p'w"x@10.0.0.2/live failed""", + "rtsp://10.0.0.2/ failed", + ), + ], + ids=[ + "credentials-token-query", + "quoted-ipv6", + "host-only", + "no-url", + "userinfo-spaces", + "userinfo-quotes", + ], +) +def test_redact_urls(line: str, expected: str) -> None: + """Verify only scheme, host and port of a URL stay in the log.""" + assert redact_urls(line) == expected + + +async def test_overlong_stderr_line_is_cut(caplog: pytest.LogCaptureFixture) -> None: + """Verify ffmpeg's log cuts an overlong line and goes on after it.""" + caplog.set_level(logging.DEBUG, "pyhap.media.ffmpeg") + stderr = asyncio.StreamReader() + stderr.feed_data(b"x" * (3 * ffmpeg_module.MAX_LOG_LINE) + b"\nnext\nlast") + stderr.feed_eof() + + await ffmpeg_module._log_output("medium", cast(Any, SimpleNamespace(stderr=stderr))) + + assert caplog.messages == [ + "ffmpeg medium: [truncated]", + "ffmpeg medium: next", + "ffmpeg medium: last", + ] + + +def test_overlong_line_is_skipped_without_buffering() -> None: + """Verify the rest of an overlong line is skipped, not buffered.""" + lines = ffmpeg_module.StderrLines() + word = b"word " * (ffmpeg_module.MAX_LOG_LINE // 5) + + assert lines.feed(word + b"tail") == [word[:-1] + b" [truncated]"] + assert lines.pending == b"" + assert lines.feed(b"y" * ffmpeg_module.MAX_LOG_LINE) == [] + assert lines.pending == b"" + assert lines.feed(b"rest\rnext\n") == [b"next"] + assert lines.feed(b"after\n") == [b"after"] + assert lines.end() == [] + + +def test_line_over_the_limit_across_reads_is_cut() -> None: + """Verify a complete line longer than the limit is cut too.""" + lines = ffmpeg_module.StderrLines() + + assert lines.feed(b"a " * 2000) == [] + assert lines.feed(b"b" * 200 + b"\n") == [b"a " * 1999 + b"a [truncated]"] + + +@pytest.mark.parametrize( + "url", + [ + b"rtsp://admin:pw@10.0.0.2/live", + b"rtsp://admin:pa ss@10.0.0.2/live", + b"'rtsp://admin:pw@10.0.0.2/live'", + ], + ids=["userinfo", "userinfo-space", "quoted"], +) +def test_cut_drops_a_url_cut_in_two(url: bytes) -> None: + """Verify a URL running over the limit is dropped, not split.""" + line = b"x " * ((ffmpeg_module.MAX_LOG_LINE - 20) // 2) + url + b" tail" + + (cut,) = ffmpeg_module.StderrLines().feed(line + b"\n") + + assert b"admin" not in cut + assert cut.endswith(b"x [truncated]") + + +def test_cut_keeps_a_complete_url() -> None: + """Verify only the URL cut in two is dropped, not an earlier one.""" + line = ( + b"x " * ((ffmpeg_module.MAX_LOG_LINE - 40) // 2) + + b"rtsp://cam1/a rtsp://admin:pw@10.0.0.2/live tail" + ) + + (cut,) = ffmpeg_module.StderrLines().feed(line + b"\n") + + assert cut.endswith(b" rtsp://cam1/a [truncated]") + + +async def test_ffmpeg_provider_without_audio(fake_ffmpeg: Path) -> None: + """Verify an input without audio opens a video-only source.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + + lease = await provider(make_session(), LOW, AUDIO) + + assert lease.source.rtp_input == ("127.0.0.1", 5000, 0) + assert lease.channel == "low" + await lease.release() + + +async def test_provider_diagnostics() -> None: + """Verify the diagnostics name each leased channel with its leases.""" + worker = FakeWorker() + provider = FFmpegSourceProvider(INPUTS, worker=cast(Any, worker)) + lease = await provider(make_session(), LOW, AUDIO) + channels = provider.diagnostics()["channels"] + assert channels["low"]["leases"] == 1 + assert channels["low"]["restarts"] == 0 + await lease.release() + + +def pull_on(fake_ffmpeg: Path, gop: float = 10.0) -> FFmpegPull: + """An ffmpeg pull running the fake ffmpeg.""" + return FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + gop, + restart_delay=0.01, + ) + + +async def test_ffmpeg_pull_restarts_after_exit(fake_ffmpeg: Path) -> None: + """Verify ffmpeg is started again when it exits while it is needed.""" + (fake_ffmpeg.parent / "exit-now").touch() + pull = pull_on(fake_ffmpeg) + + await pull.start(("127.0.0.1", 1, 2)) + await wait_until(lambda: pull.restarts >= 2) + (fake_ffmpeg.parent / "exit-now").unlink() + await pull.stop() + + assert pull.pid is None + + +async def test_ffmpeg_pull_reports_exits_and_backs_off(fake_ffmpeg: Path) -> None: + """Verify exits reach on_error with the redacted last line, and back off.""" + (fake_ffmpeg.parent / "exit-now").touch() + errors: list[tuple[str, str]] = [] + pull = FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda name, message: errors.append((name, message)), + ) + await pull.start(("127.0.0.1", 1, 2)) + await wait_until(lambda: len(errors) >= 3) + (fake_ffmpeg.parent / "exit-now").unlink() + await pull.stop() + name, message = errors[0] + assert name == "medium" + assert message.startswith("exited with 1: rtsps://192.168.1.1:7441/") + assert "pw" not in message + assert pull.diagnostics()["last_error"] == message + assert pull._failures >= 3 + + +async def test_ffmpeg_pull_backs_off_failed_starts( + fake_ffmpeg: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify failed starts keep backing off, each reported once, not counted.""" + # Every run counts as healthy, so only the failed starts may back off. + monkeypatch.setattr(ffmpeg_module, "HEALTHY_RUN", 0.0) + errors: list[str] = [] + pull = FFmpegPull( + lambda rtp_input: [str(fake_ffmpeg), f"rtp://{rtp_input[0]}"], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda _name, message: errors.append(message), + ) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + assert pid is not None + fake_ffmpeg.rename(fake_ffmpeg.with_name("gone")) + os.kill(pid, signal.SIGTERM) + await wait_until(lambda: len(errors) >= 4) + await pull.stop() + + assert errors[0].startswith("exited with") + assert all(message.startswith("could not start") for message in errors[1:]) + assert pull._failures == len(errors) + assert pull.restarts == 0 + + +async def test_failed_first_start_reaches_on_error(tmp_path: Path) -> None: + """Verify a missing ffmpeg raises and is reported to on_error.""" + errors: list[tuple[str, str]] = [] + pull = FFmpegPull( + lambda _rtp_input: [str(tmp_path / "missing-ffmpeg")], + "medium", + 10.0, + restart_delay=0.01, + on_error=lambda name, message: errors.append((name, message)), + ) + with pytest.raises(OSError): + await pull.start(("127.0.0.1", 1, 2)) + assert errors and errors[0][0] == "medium" + assert errors[0][1].startswith("could not start") + assert pull.last_error == errors[0][1] + assert pull.pid is None + + +@pytest.mark.parametrize( + ("gop", "age", "measured"), + [ + (10.0, None, None), # Started recently: its first IRAP picture is on the way. + (0.01, 0.01, None), # The last IRAP picture is recent. + (0.01, 1.0, 10.0), # The measured GOP is long: the next one is on the way. + ], + ids=["starting", "recent", "measured-gop"], +) +async def test_ffmpeg_pull_keeps_running( + fake_ffmpeg: Path, gop: float, age: float | None, measured: float | None +) -> None: + """Verify a keyframe request does not restart a healthy ffmpeg.""" + pull = pull_on(fake_ffmpeg, gop) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + await asyncio.sleep(0.05) + + pull.keyframe_requested(age, measured) + await asyncio.sleep(0.05) + + assert (pull.pid, pull.restarts) == (pid, 0) + await pull.stop() + + +@pytest.mark.parametrize( + ("gop", "age", "measured"), + [(0.01, None, None), (0.01, 1.0, None), (10.0, 1.0, 0.01)], + ids=["no-irap", "configured-gop", "measured-gop"], +) +async def test_ffmpeg_pull_restarts_for_a_keyframe( + fake_ffmpeg: Path, gop: float, age: float | None, measured: float | None +) -> None: + """Verify ffmpeg restarts when no IRAP picture came for two GOPs.""" + pull = pull_on(fake_ffmpeg, gop) + await pull.start(("127.0.0.1", 1, 2)) + pid = pull.pid + pull._started -= 1 + await asyncio.sleep(0.05) + + pull.keyframe_requested(age, measured) + await wait_until(lambda: pull.restarts == 1 and pull.pid is not None) + + assert pull.pid != pid + await pull.stop() diff --git a/tests/test_media_hevc.py b/tests/test_media_hevc.py new file mode 100644 index 00000000..43b78e84 --- /dev/null +++ b/tests/test_media_hevc.py @@ -0,0 +1,330 @@ +"""Tests for pyhap.media.hevc.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +import pytest + +from pyhap.media.hevc import ( + AccessUnit, + HevcDepacketizer, + HevcPacketizer, + is_irap, + length_prefixed, + nal_type, + payload_starts_irap, + split_aggregation_packet, + split_length_prefixed, +) + +from .media_helpers import blob, scrypted_input + + +def nal(kind: int, size: int, seed: str = "", layer: int = 0, tid: int = 1) -> bytes: + """Build a NAL unit of the given type and total size.""" + body = b"" + counter = 0 + while len(body) < size - 2: + body += hashlib.sha256(f"{seed}{kind}:{counter}".encode()).digest() + counter += 1 + header = bytes([kind << 1 | layer >> 5, (layer & 0x1F) << 3 | tid]) + return header + body[: size - 2] + + +VPS, SPS, PPS = nal(32, 24), nal(33, 40), nal(34, 8) +IDR = nal(19, 6000, "idr") +TRAIL = nal(1, 900, "trail") + + +def feed( + depacketizer: HevcDepacketizer, + units: list[list[bytes]], + sequence: int = 0, + skip: frozenset[int] = frozenset(), +) -> list[AccessUnit]: + """Send RTP payload lists (one per access unit) and collect the output.""" + out = [] + for index, payloads in enumerate(units): + for position, payload in enumerate(payloads): + if sequence not in skip: + unit = depacketizer.push( + sequence & 0xFFFF, + 3000 * (index + 1), + position == len(payloads) - 1, + payload, + ) + if unit is not None: + out.append(unit) + sequence += 1 + return out + + +def round_trip(nals: list[bytes], max_payload: int) -> list[list[bytes]]: + """Packetize and depacketize one access unit.""" + payloads = HevcPacketizer(max_payload).packetize(nals) + return nals_of(feed(HevcDepacketizer(), [payloads])) + + +def nals_of(units: list[AccessUnit]) -> list[list[bytes]]: + """Return the NAL units of each access unit.""" + return [unit.nals for unit in units] + + +def keyframes_of(units: list[AccessUnit]) -> list[bool]: + """Return whether each access unit is a keyframe.""" + return [unit.keyframe for unit in units] + + +def payload_types(nals: list[bytes], max_payload: int) -> list[int]: + """Return the RTP payload NAL types the packetizer produces.""" + return [nal_type(p) for p in HevcPacketizer(max_payload).packetize(nals)] + + +def max_payload_size(nals: list[bytes], max_payload: int) -> int: + """Return the largest RTP payload the packetizer produces.""" + return max(len(p) for p in HevcPacketizer(max_payload).packetize(nals)) + + +def input_description(case: dict[str, Any]) -> list[dict[str, Any]]: + """Describe the rebuilt RTP input the way the vector stores it.""" + return [ + {"sequence": seq, "timestamp": ts, "marker": marker, "payload": blob(data)} + for seq, ts, marker, data in scrypted_input(case) + ] + + +@pytest.mark.parametrize( + "nals", + [ + [TRAIL], + [VPS, SPS, PPS, IDR], + [nal(1, 100, "a"), nal(1, 200, "b"), nal(1, 300, "c")], + [VPS, SPS, PPS, nal(39, 30), IDR, nal(40, 12)], + [nal(19, 250_000, "big")], + [nal(1, 1200, "exact")], + [nal(1, 1201, "over")], + [nal(1, 5, "tiny", layer=3, tid=2)], + ], + ids=["single", "keyframe", "slices", "sei", "large", "exact", "over", "layer"], +) +def test_round_trip(nals: list[bytes]) -> None: + """Verify packetize then depacketize restores the access unit.""" + assert round_trip(nals, 1200) == [nals] + + +def test_packetizer_uses_ap_and_fu() -> None: + """Verify small NAL units aggregate and large ones fragment.""" + assert payload_types([VPS, SPS, PPS, IDR], 1200) == [48, 49, 49, 49, 49, 49, 49] + assert max_payload_size([VPS, SPS, PPS, IDR], 1200) == 1200 + + +def test_packetizer_single_nal_not_aggregated() -> None: + """Verify a lone NAL unit goes out as a single NAL unit packet.""" + assert payload_types([TRAIL], 1200) == [1] + + +def test_packetizer_ap_header_uses_lowest_layer_and_tid() -> None: + """Verify the AP header carries the lowest LayerId and TID.""" + payload = HevcPacketizer().packetize( + [nal(1, 10, layer=2, tid=3), nal(1, 10, layer=1, tid=2)] + )[0] + assert payload[:2] == bytes([48 << 1, 1 << 3 | 2]) + + +def test_packetizer_rejects_tiny_budget() -> None: + """Verify an unusable payload budget is rejected.""" + with pytest.raises(ValueError, match="too small"): + HevcPacketizer(3) + + +def test_parameter_sets_inserted_before_irap() -> None: + """Verify cached VPS/SPS/PPS precede an IRAP that arrived without them.""" + packetizer = HevcPacketizer() + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([TRAIL]), + packetizer.packetize([IDR]), + ], + ) + assert nals_of(units) == [[VPS, SPS, PPS, IDR], [TRAIL], [VPS, SPS, PPS, IDR]] + assert keyframes_of(units) == [True, False, True] + + +def test_parameter_sets_go_after_an_access_unit_delimiter() -> None: + """Verify an AUD stays the first NAL unit of an IRAP access unit.""" + packetizer = HevcPacketizer() + aud = nal(35, 3) + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([aud, IDR]), + ], + ) + assert units[-1].nals == [aud, VPS, SPS, PPS, IDR] + + +def test_updated_parameter_sets_are_used() -> None: + """Verify the latest parameter sets are the ones inserted.""" + packetizer = HevcPacketizer() + sps2 = nal(33, 44, "new") + units = feed( + HevcDepacketizer(), + [ + packetizer.packetize([VPS, SPS, PPS, IDR]), + packetizer.packetize([sps2, TRAIL]), + packetizer.packetize([IDR]), + ], + ) + assert units[-1].nals == [VPS, sps2, PPS, IDR] + + +def test_irap_without_known_parameter_sets_passes() -> None: + """Verify an IRAP is forwarded even before parameter sets are known.""" + units = feed(HevcDepacketizer(), [HevcPacketizer().packetize([IDR])]) + assert units[0].nals == [IDR] + + +def test_sequence_gap_drops_only_damaged_unit() -> None: + """Verify a lost packet drops its access unit and the next one survives.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + units = feed( + depacketizer, + [packetizer.packetize([IDR]), packetizer.packetize([TRAIL])], + skip=frozenset({2}), + ) + assert nals_of(units) == [[TRAIL]] + assert depacketizer.dropped == 1 + + +def test_sequence_wraps() -> None: + """Verify sequence numbers wrap at 65535 without a false gap.""" + units = feed(HevcDepacketizer(), [HevcPacketizer().packetize([IDR])], 65533) + assert units[0].nals == [IDR] + + +def test_timestamp_change_without_marker_drops_unit() -> None: + """Verify an access unit cut short by a new timestamp is dropped.""" + depacketizer = HevcDepacketizer() + assert depacketizer.push(1, 100, False, TRAIL) is None + unit = depacketizer.push(2, 200, True, TRAIL) + assert unit == AccessUnit(200, [TRAIL]) + assert depacketizer.dropped == 1 + + +@pytest.mark.parametrize( + "payloads", + [ + [bytes([49 << 1, 1, 0x01]) + b"middle", bytes([49 << 1, 1, 0x41]) + b"end"], + [bytes([49 << 1, 1, 0x81]) + b"start"], + [bytes([49 << 1, 1, 0x81]) + b"a", bytes([49 << 1, 1, 0x81]) + b"b"], + [bytes([49 << 1, 1, 0x81]) + b"a", TRAIL], + [bytes([49 << 1, 1])], + [bytes([50 << 1, 1]) + b"paci"], + [b"\x02"], + [bytes([48 << 1, 1, 0, 9]) + b"short"], + [bytes([48 << 1, 1, 0])], + ], + ids=[ + "fu-no-start", + "fu-no-end", + "fu-restart", + "fu-interrupted", + "fu-short", + "paci", + "short", + "ap-overrun", + "ap-truncated-length", + ], +) +def test_malformed_unit_dropped(payloads: list[bytes]) -> None: + """Verify malformed RTP payloads drop the access unit.""" + depacketizer = HevcDepacketizer() + assert feed(depacketizer, [payloads]) == [] + assert depacketizer.dropped == 1 + + +def test_oversized_unit_dropped() -> None: + """Verify an access unit over the size bound is dropped.""" + huge = bytes([1 << 1, 1]) + bytes(17 * 1024 * 1024) + depacketizer = HevcDepacketizer() + assert feed(depacketizer, [HevcPacketizer(60000).packetize([huge])]) == [] + assert depacketizer.dropped == 1 + + +def test_length_prefixed_round_trip() -> None: + """Verify the hvcC-style layout splits back into NAL units.""" + data = length_prefixed([VPS, SPS, IDR]) + assert data[:4] == len(VPS).to_bytes(4, "big") + assert split_length_prefixed(data) == [VPS, SPS, IDR] + + +@pytest.mark.parametrize("data", [b"\x00\x00", b"\x00\x00\x00\x05abc"]) +def test_split_length_prefixed_truncated(data: bytes) -> None: + """Verify truncated length-prefixed data is rejected.""" + with pytest.raises(ValueError, match="Truncated"): + split_length_prefixed(data) + + +def test_split_aggregation_packet_empty() -> None: + """Verify an AP without NAL units is malformed.""" + assert split_aggregation_packet(bytes([48 << 1, 1])) is None + + +def test_reset_drops_unit_in_progress() -> None: + """Verify reset forgets a partial unit, the sequence and the sets.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + first = packetizer.packetize([VPS, SPS, PPS, IDR]) + feed(depacketizer, [first]) + depacketizer.push(len(first), 6000, False, first[0]) + depacketizer.reset() + units = feed(depacketizer, [packetizer.packetize([IDR])], sequence=900) + assert nals_of(units) == [[IDR]] + assert depacketizer.parameter_sets == {} + assert depacketizer.dropped == 0 + + +def test_restart_keeps_parameter_sets() -> None: + """Verify restart forgets a partial unit and the sequence, not the sets.""" + packetizer = HevcPacketizer() + depacketizer = HevcDepacketizer() + first = packetizer.packetize([VPS, SPS, PPS, IDR]) + feed(depacketizer, [first]) + depacketizer.push(len(first), 6000, False, first[0]) + depacketizer.restart() + units = feed(depacketizer, [packetizer.packetize([IDR])], sequence=900) + assert nals_of(units) == [[VPS, SPS, PPS, IDR]] + assert depacketizer.dropped == 0 + + +@pytest.mark.parametrize( + ("payload", "irap"), + [ + (TRAIL, False), + (IDR[:50], True), + (HevcPacketizer().packetize([IDR])[0], True), + (HevcPacketizer().packetize([IDR])[1], False), + (HevcPacketizer().packetize([VPS, SPS, PPS, nal(19, 30)])[0], True), + (HevcPacketizer().packetize([VPS, SPS, PPS])[0], False), + (b"\x26", False), + ], + ids=["trail", "single", "fu-start", "fu-middle", "ap", "ap-no-irap", "short"], +) +def test_payload_starts_irap(payload: bytes, irap: bool) -> None: + """Verify IRAP detection from the RTP payload header alone.""" + assert payload_starts_irap(payload) is irap + + +@pytest.mark.parametrize( + ("kind", "irap"), + [(1, False), (16, True), (21, True), (23, True), (24, False), (32, False)], +) +def test_is_irap(kind: int, irap: bool) -> None: + """Verify the IRAP range is BLA_W_LP through RSV_IRAP_VCL23.""" + assert is_irap(nal(kind, 4)) is irap diff --git a/tests/test_media_multi_tier.py b/tests/test_media_multi_tier.py new file mode 100644 index 00000000..273082e2 --- /dev/null +++ b/tests/test_media_multi_tier.py @@ -0,0 +1,560 @@ +"""Tests for pyhap.media.multi_tier: SRTP multi-tier sessions from a source.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +import os +import signal +import socket +from typing import Any + +import pylibsrtp +import pytest + +from pyhap.media import MediaError, rtp +from pyhap.media.hevc import HevcDepacketizer +from pyhap.media.multi_tier import MultiTierSender +from pyhap.media.pool import SourcePool +from pyhap.media.source import MediaSource +from pyhap.media.worker import MediaWorker, MediaWorkerError + +from .media_peer import KEYFRAME, TRAIL, Source, wait_until + +pytestmark = pytest.mark.timeout(10) + +VIDEO_KEY = bytes(range(30)) +AUDIO_KEY = bytes(range(30, 60)) +VIDEO_SSRC = 0x1111 +AUDIO_SSRC = 0x2222 +CONTROLLER_SSRC = 0x4242 + + +class Controller: + """Plays an iOS controller: receives SRTP, sends SRTCP back.""" + + def __init__(self) -> None: + """Open the receive sockets.""" + self.video = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.audio = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + for sock in (self.video, self.audio): + sock.bind(("127.0.0.1", 0)) + sock.setblocking(False) + self.video_port = self.video.getsockname()[1] + self.audio_port = self.audio.getsockname()[1] + self._video_in = _inbound(VIDEO_KEY) + self._audio_in = _inbound(AUDIO_KEY) + self._out = pylibsrtp.Session( + pylibsrtp.Policy( + key=VIDEO_KEY, ssrc_type=pylibsrtp.Policy.SSRC_ANY_OUTBOUND + ) + ) + self._audio_out = pylibsrtp.Session( + pylibsrtp.Policy( + key=AUDIO_KEY, ssrc_type=pylibsrtp.Policy.SSRC_ANY_OUTBOUND + ) + ) + self.video_packets: list[rtp.RtpPacket] = [] + self.audio_packets: list[rtp.RtpPacket] = [] + self.rtcp = 0 + self.rtcp_types: list[list[int]] = [] + self.units: list[list[bytes]] = [] + self._depacketizer = HevcDepacketizer() + + def poll(self) -> None: + """Read and decrypt what arrived.""" + for sock, inbound, packets in ( + (self.video, self._video_in, self.video_packets), + (self.audio, self._audio_in, self.audio_packets), + ): + while True: + try: + data = sock.recv(4096) + except BlockingIOError: + break + if 192 <= data[1] <= 223: + compound = rtp.parse_rtcp(inbound.unprotect_rtcp(data)) + self.rtcp_types.append([m.packet_type for m in compound]) + self.rtcp += 1 + continue + packet = rtp.parse_rtp(inbound.unprotect(data)) + packets.append(packet) + if sock is self.video: + unit = self._depacketizer.push( + packet.sequence, packet.timestamp, packet.marker, packet.payload + ) + if unit is not None: + self.units.append(unit.nals) + + def send_pli(self, port: int) -> None: + """Ask the accessory for a keyframe on its video port.""" + pli = self._out.protect_rtcp(rtp.build_pli(CONTROLLER_SSRC, VIDEO_SSRC)) + self.video.sendto(pli, ("127.0.0.1", port)) + + def send_audio_report(self, port: int) -> None: + """Send an SRTCP receiver report on the accessory's audio port.""" + report = rtp.build_pli(CONTROLLER_SSRC + 1, AUDIO_SSRC) + self.audio.sendto(self._audio_out.protect_rtcp(report), ("127.0.0.1", port)) + + def close(self) -> None: + """Close the sockets.""" + self.video.close() + self.audio.close() + + +def _inbound(key: bytes) -> pylibsrtp.Session: + return pylibsrtp.Session( + pylibsrtp.Policy(key=key, ssrc_type=pylibsrtp.Policy.SSRC_ANY_INBOUND) + ) + + +@pytest.fixture +async def controller() -> AsyncIterator[Controller]: + """Yield a controller and close it afterwards.""" + peer = Controller() + yield peer + peer.close() + + +@pytest.fixture +async def source() -> AsyncIterator[MediaSource]: + """Yield a started source and close it afterwards.""" + media = MediaSource() + await media.start() + yield media + media.close() + + +def make_sender(controller: Controller, **kwargs: Any) -> MultiTierSender: + """A sender towards the controller.""" + options: dict[str, Any] = { + "address": "127.0.0.1", + "video_port": controller.video_port, + "audio_port": controller.audio_port, + "video_key": VIDEO_KEY, + "audio_key": AUDIO_KEY, + "video_ssrc": VIDEO_SSRC, + "audio_ssrc": AUDIO_SSRC, + "keyframe_request_interval": 0, + "rtcp_notify_interval": 0, + } + return MultiTierSender(**(options | kwargs)) + + +async def received(controller: Controller, predicate: Any) -> None: + """Poll the controller until ``predicate()`` holds.""" + + def check() -> bool: + controller.poll() + return bool(predicate()) + + await wait_until(check) + + +async def test_video_starts_with_a_keyframe( + controller: Controller, source: MediaSource +) -> None: + """Verify SRTP video decrypts, carries the session SSRC and starts at IRAP.""" + sender = make_sender(controller) + requests: list[tuple[float | None, float | None]] = [] + sender.on_keyframe_request = lambda age, gop: requests.append((age, gop)) + video_port, audio_port = await sender.bind() + assert video_port and audio_port + await sender.start(source) + assert requests == [(None, None)] + camera = Source(source.rtp_input) + camera.send_video([TRAIL]) + await asyncio.sleep(0.05) + camera.send_video(KEYFRAME) + camera.send_video([TRAIL]) + await received(controller, lambda: len(controller.units) == 2) + assert controller.units == [KEYFRAME, [TRAIL]] + packets = controller.video_packets + assert {p.ssrc for p in packets} == {VIDEO_SSRC} + assert {p.payload_type for p in packets} == {99} + sequences = [p.sequence for p in packets] + assert sequences == [(sequences[0] + i) & 0xFFFF for i in range(len(packets))] + assert packets[-1].timestamp - packets[0].timestamp == 3000 + camera.close() + await sender.close() + assert sender.closed + + +async def test_audio_is_sent_as_srtp( + controller: Controller, source: MediaSource +) -> None: + """Verify Opus goes out with the audio SSRC and key, and not without audio.""" + sender = make_sender(controller) + await sender.start(source) + camera = Source(source.rtp_input) + camera.send_audio(b"opus-1") + await received(controller, lambda: controller.audio_packets) + packet = controller.audio_packets[0] + assert (packet.ssrc, packet.payload_type, packet.payload) == ( + AUDIO_SSRC, + 110, + b"opus-1", + ) + assert packet.marker + await sender.close() + + silent = make_sender(controller) + await silent.start(source, audio=False) + camera.send_audio(b"opus-2") + await asyncio.sleep(0.1) + controller.poll() + assert len(controller.audio_packets) == 1 + camera.close() + await silent.close() + + +async def test_controller_rtcp_keeps_alive_and_asks_for_keyframes( + controller: Controller, source: MediaSource +) -> None: + """Verify authentic controller RTCP calls on_rtcp, a PLI asks for a keyframe.""" + sender = make_sender(controller) + alive: list[None] = [] + requests: list[float | None] = [] + sender.on_rtcp = lambda: alive.append(None) + sender.on_keyframe_request = lambda age, _gop: requests.append(age) + video_port, audio_port = await sender.bind() + await sender.start(source) + controller.send_pli(video_port) + await wait_until(lambda: len(requests) == 2 and bool(alive)) + # Unauthenticated datagrams, e.g. with a spoofed address, do not count. + # Each socket delivers in order: the junk is handled before the next + # authentic packet, which proves the junk was not counted. + controller.video.sendto(b"\x81\xc9junk", ("127.0.0.1", video_port)) + controller.send_pli(video_port) + await wait_until(lambda: len(requests) == 3) + assert len(alive) == 2 + controller.audio.sendto(b"\x81\xc9junk", ("127.0.0.1", audio_port)) + controller.send_audio_report(audio_port) + controller.send_audio_report(audio_port) + await wait_until(lambda: len(alive) >= 4) + assert len(alive) == 4 + await sender.close() + + +async def test_rtcp_from_others_is_ignored( + controller: Controller, source: MediaSource +) -> None: + """Verify datagrams from another address do not keep the session alive.""" + sender = make_sender(controller, address="127.0.0.2") + alive: list[None] = [] + sender.on_rtcp = lambda: alive.append(None) + video_port, _ = await sender.bind() + await sender.start(source) + controller.send_pli(video_port) + await asyncio.sleep(0.1) + assert not alive + await sender.close() + + +async def test_bye_is_a_compound_packet( + controller: Controller, source: MediaSource +) -> None: + """Verify the BYE on close comes with a report and a CNAME (RFC 3550 6.1). + + Audio has sent nothing, so it reports as a receiver; video as a sender. + """ + sender = make_sender(controller) + await sender.start(source) + camera = Source(source.rtp_input) + camera.send_video(KEYFRAME) + await received(controller, lambda: controller.units) + await sender.close() + byes = [ + [rtp.RTCP_SR, rtp.RTCP_SDES, rtp.RTCP_BYE], + [rtp.RTCP_RR, rtp.RTCP_SDES, rtp.RTCP_BYE], + ] + await received(controller, lambda: all(b in controller.rtcp_types for b in byes)) + camera.close() + + +async def test_close_while_binding_releases_the_ports( + controller: Controller, source: MediaSource +) -> None: + """Verify a session closed during bind leaves no port open.""" + sender = make_sender(controller) + binding = asyncio.ensure_future(sender.bind()) + await asyncio.sleep(0) + await sender.close() + with pytest.raises(MediaError): + await binding + for port in (sender._video_port, sender._audio_port): + assert port is None or port.transport is None or port.transport.is_closing() + + +async def test_close_waits_for_an_ending_session( + controller: Controller, source: MediaSource +) -> None: + """Verify close() during an ending of its own returns once ports are released.""" + sender = make_sender(controller) + closed: list[str] = [] + sender.on_closed = closed.append + await sender.start(source) + sender.on_source_closed("gone") + await sender.close() + assert closed == ["media source ended: gone"] + assert sender._video_port is not None + assert sender._video_port.transport is not None + assert sender._video_port.transport.is_closing() + + +async def test_sender_reports(controller: Controller, source: MediaSource) -> None: + """Verify SRTCP sender reports reach the controller once media flowed.""" + sender = make_sender(controller) + await sender.start(source) + camera = Source(source.rtp_input) + camera.send_video(KEYFRAME) + await received(controller, lambda: controller.rtcp >= 1) + camera.close() + await sender.close() + + +async def test_switch_continues_the_stream( + controller: Controller, source: MediaSource +) -> None: + """Verify a switch keeps SSRC and sequence and waits for the next IRAP.""" + other = MediaSource() + await other.start() + sender = make_sender(controller) + await sender.start(source) + first = Source(source.rtp_input) + first.send_video(KEYFRAME) + await received(controller, lambda: len(controller.units) == 1) + await sender.switch(other) + assert sender.source is other + first.send_video([TRAIL]) + second = Source(other.rtp_input, ssrc=0xCCCC) + second.send_video([TRAIL]) + await asyncio.sleep(0.05) + second.send_video(KEYFRAME) + await received(controller, lambda: len(controller.units) == 2) + assert controller.units == [KEYFRAME, KEYFRAME] + sequences = [p.sequence for p in controller.video_packets] + assert sequences == [(sequences[0] + i) & 0xFFFF for i in range(len(sequences))] + assert {p.ssrc for p in controller.video_packets} == {VIDEO_SSRC} + first.close() + second.close() + await sender.close() + other.close() + + +async def test_switch_keeps_rtp_time_going_with_the_same_input_ssrc( + controller: Controller, source: MediaSource +) -> None: + """Verify RTP time continues after a switch even if the input SSRC repeats.""" + other = MediaSource() + await other.start() + sender = make_sender(controller, keyframe_request_interval=60) + requests: list[float | None] = [] + sender.on_keyframe_request = lambda age, _gop: requests.append(age) + await sender.start(source) + first = Source(source.rtp_input) + first.timestamp = 900_000 + first.send_video(KEYFRAME) + await received(controller, lambda: len(controller.units) == 1) + before = len(requests) + await sender.switch(other) + # The switch asks the new source for a keyframe, whatever the rate limit. + assert len(requests) == before + 1 + second = Source(other.rtp_input) + second.timestamp = 1000 + second.send_video(KEYFRAME) + await received(controller, lambda: len(controller.units) == 2) + stamps = [p.timestamp for p in controller.video_packets] + assert stamps[-1] - stamps[0] > 0 + assert stamps[-1] - stamps[0] < 90000 + first.close() + second.close() + await sender.close() + other.close() + + +async def test_start_waits_for_parameter_sets( + controller: Controller, source: MediaSource +) -> None: + """Verify the stream starts at an IRAP picture that carries VPS, SPS, PPS.""" + sender = make_sender(controller) + await sender.start(source) + camera = Source(source.rtp_input) + camera.send_video([KEYFRAME[-1]]) + camera.send_video([TRAIL]) + await asyncio.sleep(0.05) + camera.send_video(KEYFRAME) + await received(controller, lambda: len(controller.units) == 1) + assert controller.units == [KEYFRAME] + camera.close() + await sender.close() + + +async def test_own_packets_sent_back_do_not_keep_alive( + controller: Controller, source: MediaSource +) -> None: + """Verify the accessory's own SRTCP, reflected back, is not the controller.""" + sender = make_sender(controller) + alive: list[None] = [] + sender.on_rtcp = lambda: alive.append(None) + video_port, _ = await sender.bind() + await sender.start(source) + own = controller._out.protect_rtcp(rtp.build_pli(VIDEO_SSRC, VIDEO_SSRC)) + controller.video.sendto(own, ("127.0.0.1", video_port)) + controller.send_pli(video_port) + await wait_until(lambda: bool(alive)) + await asyncio.sleep(0.05) + assert len(alive) == 1 + await sender.close() + + +async def test_idle_input_ends_the_session( + controller: Controller, source: MediaSource +) -> None: + """Verify the sender ends itself without video input.""" + sender = make_sender(controller, idle_timeout=0.1) + closed: list[str] = [] + sender.on_closed = closed.append + await sender.start(source) + await wait_until(lambda: bool(closed)) + assert closed == ["media input idle"] + assert sender.closed + + +async def test_closed_source_ends_the_session( + controller: Controller, source: MediaSource +) -> None: + """Verify the sender ends with its source.""" + sender = make_sender(controller) + closed: list[str] = [] + sender.on_closed = closed.append + await sender.start(source) + source.close("gone") + await wait_until(lambda: bool(closed)) + assert closed == ["media source ended: gone"] + + +async def test_close_is_quiet_and_final( + controller: Controller, source: MediaSource +) -> None: + """Verify close() calls no callback and a closed sender refuses to start.""" + sender = make_sender(controller) + closed: list[str] = [] + sender.on_closed = closed.append + await sender.bind() + await sender.close() + await sender.close() + assert not closed + with pytest.raises(Exception, match="closed"): + await sender.start(source) + + +def test_key_length_is_checked(controller: Controller) -> None: + """Verify a key without its salt is refused.""" + with pytest.raises(ValueError, match="30 bytes"): + make_sender(controller, video_key=bytes(16)) + + +@pytest.fixture +async def worker() -> AsyncIterator[MediaWorker]: + """Yield a started media worker and stop it afterwards.""" + started = MediaWorker() + await started.start() + yield started + await started.close() + + +def sender_config(controller: Controller, **kwargs: Any) -> dict[str, Any]: + """The sender arguments towards the controller.""" + options: dict[str, Any] = { + "address": "127.0.0.1", + "video_port": controller.video_port, + "audio_port": controller.audio_port, + "video_key": VIDEO_KEY, + "audio_key": AUDIO_KEY, + "video_ssrc": VIDEO_SSRC, + "audio_ssrc": AUDIO_SSRC, + "keyframe_request_interval": 0, + "rtcp_notify_interval": 0, + } + return options | kwargs + + +@pytest.mark.timeout(30) +async def test_sender_in_worker(controller: Controller, worker: MediaWorker) -> None: + """Verify a multi-tier session runs in the worker with its events.""" + source = await worker.open_source() + sender = worker.create_multi_tier_sender(**sender_config(controller)) + alive: list[None] = [] + requests: list[float | None] = [] + sender.on_rtcp = lambda: alive.append(None) + sender.on_keyframe_request = lambda age, _gop: requests.append(age) + video_port, audio_port = await sender.bind() + assert video_port and audio_port and sender.ports == (video_port, audio_port) + await sender.start(source, audio=True) + assert sender.started + camera = Source(source.rtp_input) + camera.send_video(KEYFRAME) + camera.send_audio(b"opus") + await received( + controller, lambda: controller.units == [KEYFRAME] and controller.audio_packets + ) + controller.send_pli(video_port) + await wait_until(lambda: len(requests) == 2 and bool(alive)) + other = await worker.open_source() + await sender.switch(other) + camera.close() + await sender.close() + assert sender.closed + await source.close() + await other.close() + + +@pytest.mark.timeout(30) +async def test_worker_exit_ends_the_sender( + controller: Controller, worker: MediaWorker +) -> None: + """Verify a multi-tier session ends with its worker.""" + sender = worker.create_multi_tier_sender(**sender_config(controller)) + closed: list[str] = [] + sender.on_closed = closed.append + await sender.bind() + pid = worker.pid + assert pid is not None + os.kill(pid, signal.SIGKILL) + await wait_until(lambda: bool(closed)) + assert "media worker exited" in closed[0] + assert sender.closed + + +@pytest.mark.timeout(30) +async def test_worker_sender_refuses_bad_use( + controller: Controller, worker: MediaWorker +) -> None: + """Verify unknown options, a foreign source and an unbound start are refused.""" + with pytest.raises(TypeError, match="Unknown multi-tier sender options"): + worker.create_multi_tier_sender(**sender_config(controller), pacing=1) + source = await worker.open_source() + sender = worker.create_multi_tier_sender(**sender_config(controller)) + with pytest.raises(MediaWorkerError, match="No multi-tier session"): + await sender.start(source) + await sender.bind() + with pytest.raises(MediaWorkerError, match="already bound"): + await sender.bind() + other_worker = MediaWorker() + try: + foreign = await other_worker.open_source() + with pytest.raises(MediaWorkerError, match="another worker"): + await sender.start(foreign) + finally: + await other_worker.close() + await sender.close() + await source.close() + + +async def test_pool_creates_senders_in_process(controller: Controller) -> None: + """Verify a pool without a worker makes in-process multi-tier senders.""" + pool = SourcePool() + sender = pool.create_multi_tier_sender(**sender_config(controller)) + assert isinstance(sender, MultiTierSender) + await sender.close() diff --git a/tests/test_media_multi_tier_e2e.py b/tests/test_media_multi_tier_e2e.py new file mode 100644 index 00000000..85cf55cd --- /dev/null +++ b/tests/test_media_multi_tier_e2e.py @@ -0,0 +1,413 @@ +"""HAP multi-tier sessions end to end through MultiTierMediaDelegate. + +A loopback controller writes Setup Endpoints and RTP Streaming Control and +decrypts the SRTP of a synthetic camera channel; once with the senders in +process, once in the media worker. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +from types import SimpleNamespace +from typing import Any +from uuid import UUID + +import pytest + +from pyhap import hksv3 +from pyhap.accessory_driver import AccessoryDriver +from pyhap.camera import Camera +from pyhap.camera_multi_tier import MultiTierController +from pyhap.camera_stream import StreamError +from pyhap.media.multi_tier_delegate import MultiTierMediaDelegate +from pyhap.media.pool import SourcePool +from pyhap.media.worker import MediaWorker +from pyhap.util import base64_to_bytes, to_base64_str + +from .media_hap import ( + SyntheticPull, + bridged, +) +from .media_peer import KEYFRAME, TRAIL, wait_until +from .test_media_multi_tier import AUDIO_KEY, VIDEO_KEY, Controller as SrtpController + +pytestmark = pytest.mark.timeout(30) + +SENSOR = UUID("00112233-4455-6677-8899-aabbccddeeff") +SESSION = UUID("acccc6cc-1505-6345-158d-a974b4509006") +CLIENT = ("127.0.0.1", 50000) +CONTROLLER_VIDEO_SSRC = 0x1234 + + +@pytest.fixture(params=["in-process", "worker"]) +async def media_worker( + request: pytest.FixtureRequest, +) -> AsyncIterator[MediaWorker | None]: + """No worker (senders in process), or a started worker.""" + if request.param == "in-process": + yield None + return + worker = MediaWorker() + await worker.start() + yield worker + await worker.close() + + +class Provider: + """Leases one synthetic channel per tier quality from a pool.""" + + def __init__(self, pool: SourcePool) -> None: + """Record the pulls it makes.""" + self.pool = pool + self.pulls: dict[str, SyntheticPull] = {} + + async def __call__( + self, _session: Any, tier: hksv3.VideoStreamTier, _audio: Any + ) -> Any: + name = hksv3.VideoQuality(tier.quality).name.lower() + return await self.pool.lease(name, partial_pull(self.pulls, name), name=name) + + +def partial_pull( + pulls: dict[str, SyntheticPull], name: str +) -> Callable[[], SyntheticPull]: + """Make the pull of a channel and remember it.""" + + def make() -> SyntheticPull: + pulls[name] = SyntheticPull() + return pulls[name] + + return make + + +@pytest.fixture +async def provider(media_worker: MediaWorker | None) -> AsyncIterator[Provider]: + """A provider on a pool in the worker or in process.""" + pool = SourcePool(media_worker) + yield Provider(pool) + await pool.close() + + +@pytest.fixture +async def camera( + stream_driver: AccessoryDriver, + stream_camera: Callable[..., Camera], + provider: Provider, +) -> AsyncIterator[Camera]: + """A camera whose multi-tier sessions lease from the provider.""" + multi_tier = MultiTierMediaDelegate(provider, sender_options={"idle_timeout": 1.0}) + accessory = bridged( + stream_driver, stream_camera(multi_tier={"delegate": multi_tier}) + ) + yield accessory + await accessory.stop() + await multi_tier.close() + + +def _controller(camera: Camera) -> MultiTierController: + return camera.multi_tier_controllers[SENSOR] + + +async def setup(camera: Camera, peer: SrtpController) -> hksv3.SetupEndpointsResponse: + """Write Setup Endpoints towards the loopback controller.""" + suite = hksv3.SRTPCryptoSuite.AES_CM_128_HMAC_SHA1_80 + request = hksv3.SetupEndpoints( + SESSION, + hksv3.EndpointAddress("127.0.0.1", False, peer.video_port, peer.audio_port), + hksv3.SRTPParameters(suite, VIDEO_KEY[:16], VIDEO_KEY[16:]), + hksv3.SRTPParameters(suite, AUDIO_KEY[:16], AUDIO_KEY[16:]), + ) + answer_value = await _controller(camera)._set_setup_endpoints( + to_base64_str(request.encode()), CLIENT + ) + return hksv3.SetupEndpointsResponse.decode(base64_to_bytes(answer_value)) + + +async def control(camera: Camera, command: int, tier: int | None = None) -> int: + """Write RTP Streaming Control, return the status.""" + data = hksv3.encode_item(1, SESSION.bytes) + hksv3.encode_item(2, bytes([command])) + if tier is not None: + data += hksv3.encode_item(3, tier.to_bytes(4, "little")) + data += hksv3.encode_item(4, CONTROLLER_VIDEO_SSRC.to_bytes(4, "little")) + data += hksv3.encode_item(5, (1).to_bytes(4, "little")) + data += hksv3.encode_item(6, (0x5678).to_bytes(4, "little")) + answer_value = await _controller(camera)._set_streaming_control( + to_base64_str(data), CLIENT + ) + fields = dict(hksv3.decode_items(base64_to_bytes(answer_value))) + return fields[2][0] + + +def tier_of(camera: Camera, quality: hksv3.VideoQuality) -> int: + """The identifier of the advertised tier of a quality.""" + return next(t.identifier for t in _controller(camera).tiers if t.quality == quality) + + +async def frames(peer: SrtpController, count: int) -> None: + """Wait until the controller decrypted ``count`` access units.""" + + def check() -> bool: + peer.poll() + return len(peer.units) >= count + + await wait_until(check) + + +async def test_multi_tier_session_end_to_end( + camera: Camera, provider: Provider +) -> None: + """Verify setup, start, tier change and end deliver decryptable SRTP.""" + peer = SrtpController() + response = await setup(camera, peer) + assert response.status == hksv3.SetupEndpointsStatus.SUCCESS + assert response.address is not None + assert response.address.video_port not in (0, peer.video_port) + assert provider.pool.counts() == {} + + assert await control(camera, 2, tier_of(camera, hksv3.VideoQuality.MEDIUM)) == 0 + await frames(peer, 3) + assert peer.units[:3] == [KEYFRAME, [TRAIL], [TRAIL]] + assert {p.ssrc for p in peer.video_packets} == {response.video_ssrc} + assert provider.pool.counts() == {"medium": (1, 1)} + + peer.poll() + received = len(peer.units) + assert await control(camera, 2, tier_of(camera, hksv3.VideoQuality.LOW)) == 0 + assert provider.pool.counts() == {"low": (1, 1)} + assert provider.pulls["medium"].stops == 1 + # The new channel's pull is asked for a keyframe right away. + assert provider.pulls["low"].keyframe_requests + await frames(peer, received + 3) + assert KEYFRAME in peer.units[received:] + + assert await control(camera, 1) == 0 + assert provider.pool.counts() == {} + assert provider.pulls["low"].stops == 1 + peer.close() + + +async def test_controller_rtcp_keeps_the_session( + camera: Camera, provider: Provider +) -> None: + """Verify the controller's RTCP on the announced port restarts the timer.""" + peer = SrtpController() + response = await setup(camera, peer) + assert response.address is not None + assert await control(camera, 2, tier_of(camera, hksv3.VideoQuality.MEDIUM)) == 0 + session = _controller(camera).sessions[SESSION] + calls: list[None] = [] + original = session.keep_alive + + def keep_alive() -> None: + calls.append(None) + original() + + session.keep_alive = keep_alive # type: ignore[method-assign] + delegate = _controller(camera).delegate + assert isinstance(delegate, MultiTierMediaDelegate) + sender: Any = delegate.sender(session) + assert sender is not None + sender.on_rtcp = keep_alive + peer.send_pli(response.address.video_port) + await wait_until(lambda: bool(calls)) + assert await control(camera, 1) == 0 + peer.close() + + +async def test_stalled_source_ends_the_session( + camera: Camera, provider: Provider +) -> None: + """Verify a sender that ends on its own ends its HAP session.""" + peer = SrtpController() + await setup(camera, peer) + assert await control(camera, 2, tier_of(camera, hksv3.VideoQuality.MEDIUM)) == 0 + await frames(peer, 1) + await provider.pulls["medium"].stop() + controller = _controller(camera) + + async def ended() -> None: + while controller.sessions: + await asyncio.sleep(0.05) + + delegate = controller.delegate + assert isinstance(delegate, MultiTierMediaDelegate) + await asyncio.wait_for(ended(), 20) + assert delegate.session_count == 0 + peer.close() + + +async def test_same_channel_keeps_the_stream( + camera: Camera, provider: Provider +) -> None: + """Verify a start with a tier of the same channel keeps the source.""" + peer = SrtpController() + await setup(camera, peer) + medium = tier_of(camera, hksv3.VideoQuality.MEDIUM) + assert await control(camera, 2, medium) == 0 + assert await control(camera, 2, medium) == 0 + assert provider.pool.counts() == {"medium": (1, 1)} + assert provider.pulls["medium"].stops == 0 + assert await control(camera, 1) == 0 + peer.close() + + +async def test_provider_failure_refuses_the_start( + camera: Camera, provider: Provider, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify a provider error answers the start with an error.""" + + async def fail(*_: Any) -> Any: + raise RuntimeError("no input") + + monkeypatch.setattr(Provider, "__call__", fail) + peer = SrtpController() + await setup(camera, peer) + status = await control(camera, 2, tier_of(camera, hksv3.VideoQuality.MEDIUM)) + assert status == hksv3.RTPStreamingStatus.ERROR + assert provider.pool.counts() == {} + peer.close() + + +def test_delegate_needs_a_pool() -> None: + """Verify a provider without a pool needs one passed.""" + + async def provider(*_: Any) -> Any: + return None + + with pytest.raises(TypeError, match="SourcePool"): + MultiTierMediaDelegate(provider) + assert MultiTierMediaDelegate(provider, SourcePool()).session_count == 0 + + +class _FakeLease: + """A lease whose activation can fail.""" + + def __init__(self, fail: bool) -> None: + self.source = object() + self.fail = fail + self.released = 0 + self.keyframes: list[tuple[float | None, float | None]] = [] + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + self.keyframes.append((age, gop)) + + async def activate(self) -> None: + if self.fail: + raise RuntimeError("pull failed") + + async def release(self) -> None: + self.released += 1 + + +class _FakeSender: + """A multi-tier sender that starts once, like the real one; switch can be slow.""" + + on_rtcp: Any = None + on_closed: Any = None + on_keyframe_request: Any = None + + def __init__(self) -> None: + self.switching = asyncio.Event() + self.started = False + + async def bind(self) -> tuple[int, int]: + return 1, 2 + + async def start(self, *_args: Any, **_kwargs: Any) -> None: + if self.started: + raise RuntimeError("Multi-tier session already started") + self.started = True + + async def switch(self, _source: Any) -> None: + self.switching.set() + await asyncio.sleep(10) + + async def close(self) -> None: + return None + + +class _Session(SimpleNamespace): + """A hashable stand-in for a MultiTierSession.""" + + __hash__ = object.__hash__ + + +def _fake_session() -> Any: + return _Session( + id=SESSION, + address="127.0.0.1", + is_ipv6=False, + video_port=1, + audio_port=2, + video_srtp_key=bytes(16), + video_srtp_salt=bytes(14), + audio_srtp_key=bytes(16), + audio_srtp_salt=bytes(14), + video_ssrc=1, + audio_ssrc=2, + keep_alive=lambda: None, + end=lambda _reason: None, + ) + + +async def test_failed_start_releases_its_lease() -> None: + """Verify a start that fails, and the repeated start, leak no lease.""" + leases = [_FakeLease(fail=True), _FakeLease(fail=False)] + + async def provider(*_: Any) -> Any: + return leases.pop(0) + + pool = SourcePool() + setattr(pool, "create_multi_tier_sender", lambda **_options: _FakeSender()) # noqa: B010 + delegate = MultiTierMediaDelegate(provider, pool) + session = _fake_session() + tier = hksv3.VideoStreamTier(2, hksv3.VideoQuality.MEDIUM, 1500, 1280, 720, 30) + await delegate.handle_prepare(session) + first, second = leases + with pytest.raises(StreamError): + await delegate.handle_start(session, tier, None) + assert first.released == 1 + await delegate.handle_start(session, tier, None) + sender: Any = delegate.sender(session) + assert sender is not None and sender.started + await delegate.handle_end(session) + assert (first.released, second.released) == (1, 1) + + +async def test_cancelled_tier_change_releases_the_new_lease() -> None: + """Verify a tier change cancelled mid-switch gives the new lease back.""" + leases = [_FakeLease(fail=False), _FakeLease(fail=False)] + + async def provider(*_: Any) -> Any: + return leases.pop(0) + + pool = SourcePool() + sender = _FakeSender() + setattr(pool, "create_multi_tier_sender", lambda **_options: sender) # noqa: B010 + delegate = MultiTierMediaDelegate(provider, pool) + session = _fake_session() + tier = hksv3.VideoStreamTier(2, hksv3.VideoQuality.MEDIUM, 1500, 1280, 720, 30) + await delegate.handle_prepare(session) + old, new = leases + await delegate.handle_start(session, tier, None) + change = asyncio.ensure_future(delegate.handle_reconfigure(session, tier, None)) + await sender.switching.wait() + change.cancel() + with pytest.raises(asyncio.CancelledError): + await change + assert new.released == 1 + assert old.released == 0 + # The session keeps the old lease after the failed change. + delegate._keyframe_requested(delegate._media[session], 1.0, 2.0) + assert old.keyframes == [(1.0, 2.0)] + await delegate.handle_end(session) + assert old.released == 1 + + +def test_unknown_sender_options_are_refused() -> None: + """Verify a typo in sender_options fails when the delegate is made.""" + with pytest.raises(TypeError, match="idle_timout"): + MultiTierMediaDelegate( + Provider(SourcePool()), SourcePool(), sender_options={"idle_timout": 1} + ) diff --git a/tests/test_media_pool.py b/tests/test_media_pool.py new file mode 100644 index 00000000..32af1a71 --- /dev/null +++ b/tests/test_media_pool.py @@ -0,0 +1,147 @@ +"""Tests for pyhap.media.pool: sharing one source and pull per channel.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from pyhap.media import MediaError +from pyhap.media.pool import SourcePool + +pytestmark = pytest.mark.timeout(5) + + +class Source: + """A media source that records its close.""" + + def __init__(self, port: int) -> None: + """Bind nothing, take ``port`` as the video port.""" + self.on_closed: Callable[[str], None] | None = None + self.closed = False + self.rtp_input = ("127.0.0.1", port, port + 1) + self.has_audio = True + self.close_reasons: list[str] = [] + + async def close(self, reason: str = "closed") -> None: + """Close once.""" + self.closed = True + self.close_reasons.append(reason) + + +class Pull: + """A channel pull that records its starts and stops.""" + + def __init__(self) -> None: + """Not running.""" + self.running = False + self.starts = 0 + self.stops = 0 + + async def start(self, rtp_input: tuple[str, int, int]) -> None: + """Start pulling.""" + self.running = True + self.starts += 1 + + async def stop(self) -> None: + """Stop pulling.""" + self.running = False + self.stops += 1 + + def keyframe_requested(self, age: float | None, gop: float | None) -> None: + """Ignore keyframe requests.""" + + +@pytest.fixture +def sources(monkeypatch: pytest.MonkeyPatch) -> list[Source]: + """The sources the pool opens, in process and without ports.""" + opened: list[Source] = [] + + async def _open_source(self: SourcePool, audio: bool) -> Source: + await asyncio.sleep(0) + source = Source(5000 + 2 * len(opened)) + opened.append(source) + return source + + monkeypatch.setattr(SourcePool, "_open_source", _open_source) + return opened + + +async def test_leases_share_one_source_and_pull(sources: list[Source]) -> None: + """Verify the pull runs while a lease is active and the last one closes.""" + pool = SourcePool() + pull = Pull() + first = await pool.lease("high", lambda: pull) + second = await pool.lease("high", Pull) + assert len(sources) == 1 + assert first.source is second.source + + await first.activate() + await second.activate() + assert pull.starts == 1 + await first.release() + assert pull.running + await second.release() + + assert not pull.running + assert sources[0].close_reasons == ["no sessions left"] + assert pool.counts() == {} + + +async def test_cancelled_lease_is_given_back(sources: list[Source]) -> None: + """Verify a lease whose caller gave up is released once it is made.""" + pool = SourcePool() + lease = asyncio.ensure_future(pool.lease("high", Pull)) + await asyncio.sleep(0) + lease.cancel() + with pytest.raises(asyncio.CancelledError): + await lease + for _ in range(20): + if sources and sources[0].closed: + break + await asyncio.sleep(0) + + assert sources[0].close_reasons == ["no sessions left"] + assert pool.counts() == {} + + +async def test_source_that_ends_is_forgotten(sources: list[Source]) -> None: + """Verify an ended source stops the pull and the next lease opens anew.""" + pool = SourcePool() + pull = Pull() + lease = await pool.lease("high", lambda: pull) + idle = await pool.lease("high", Pull) + await lease.activate() + + source = sources[0] + assert source.on_closed is not None + source.on_closed("worker exited") + for _ in range(20): + if not pull.running: + break + await asyncio.sleep(0) + + assert not pull.running + assert pool.counts() == {} + with pytest.raises(MediaError, match="closed"): + await idle.activate() + other = await pool.lease("high", Pull) + assert other.source is not source + for done in (lease, idle, other): + await done.release() + + +async def test_close_stops_pulls_and_sources(sources: list[Source]) -> None: + """Verify closing the pool stops every pull and closes every source.""" + pool = SourcePool() + pulls = {"high": Pull(), "low": Pull()} + leases = [await pool.lease(key, lambda key=key: pulls[key]) for key in pulls] + for lease in leases: + await lease.activate() + + await pool.close() + + assert not any(pull.running for pull in pulls.values()) + assert [source.close_reasons for source in sources] == [["pool closed"]] * 2 + assert pool.counts() == {} diff --git a/tests/test_media_rtp.py b/tests/test_media_rtp.py new file mode 100644 index 00000000..9aec7b18 --- /dev/null +++ b/tests/test_media_rtp.py @@ -0,0 +1,159 @@ +"""Tests for pyhap.media.rtp.""" + +from __future__ import annotations + +from operator import attrgetter +import struct + +import pytest + +from pyhap.media import rtp +from pyhap.media.rtp import RtpPacket + +PACKET = RtpPacket( + payload_type=100, + sequence=65535, + timestamp=0xFFFFFFFF, + ssrc=0x01020304, + marker=True, + payload=b"data", +) + + +def test_rtp_round_trip() -> None: + """Verify an RTP packet survives build and parse.""" + data = rtp.build_rtp(PACKET) + assert data[:2] == bytes([0x80, 0x80 | 100]) + assert rtp.parse_rtp(data) == PACKET + + +def test_rtp_parse_skips_csrc_extension_and_padding() -> None: + """Verify CSRCs, a header extension and padding are skipped.""" + header = struct.pack(">BBHII", 0xB1, 96, 7, 9000, 5) + csrc = struct.pack(">I", 1) + extension = struct.pack(">HHI", 0xBEDE, 1, 0) + data = header + csrc + extension + b"payload" + b"\x00\x00\x03" + assert rtp.parse_rtp(data) == RtpPacket(96, 7, 9000, 5, False, b"payload") + + +@pytest.mark.parametrize( + "data", + [ + b"\x80\x60", + bytes([0x40]) + bytes(11), + bytes([0x90]) + bytes(11), + bytes([0xA0]) + bytes(11) + b"\x00", + bytes([0xA0]) + bytes(11) + b"\x20", + bytes([0x8F]) + bytes(11), + ], + ids=["short", "version", "extension", "zero-padding", "padding", "csrc"], +) +def test_rtp_parse_rejects(data: bytes) -> None: + """Verify malformed RTP raises ValueError.""" + with pytest.raises(ValueError, match="RTP"): + rtp.parse_rtp(data) + + +def test_parse_pli() -> None: + """Verify a PLI names its media SSRC.""" + (message,) = rtp.parse_rtcp(rtp.build_pli(1, 2)) + assert message.is_keyframe_request + assert (message.sender_ssrc, message.media_ssrcs) == (1, [2]) + + +def test_parse_fir() -> None: + """Verify a FIR takes its targets from the FCI.""" + fir = struct.pack(">BBHIIIBxxx", 0x84, 206, 4, 1, 0, 77, 3) + (message,) = rtp.parse_rtcp(fir) + assert message.is_keyframe_request + assert message.media_ssrcs == [77] + + +def test_parse_nack_with_bitmask() -> None: + """Verify a NACK expands its lost-packet bitmask across the wrap.""" + nack = rtp.build_nack(1, 2, [65534])[:-2] + struct.pack(">H", 0b101) + (message,) = rtp.parse_rtcp(nack) + assert message.is_nack + assert message.lost == [65534, 65535, 1] + + +def test_parse_compound_sr_sdes_bye() -> None: + """Verify a compound SR, SDES and BYE parses part by part.""" + compound = ( + rtp.build_sender_report(9, rtp.ntp_time(0), 1, 2, 3) + + rtp.build_sdes(9, "cname") + + rtp.build_bye([9, 10]) + ) + messages = rtp.parse_rtcp(compound) + assert list(map(attrgetter("packet_type"), messages)) == [200, 202, 203] + assert messages[2].media_ssrcs == [9, 10] + + +def test_parse_rtcp_bye_without_sources() -> None: + """Test a BYE without sources is parsed and does not end the compound.""" + bye = bytes([0x80, rtp.RTCP_BYE, 0, 0]) + pli = rtp.build_pli(1, 2) + messages = rtp.parse_rtcp(bye + pli) + assert [m.packet_type for m in messages] == [rtp.RTCP_BYE, rtp.RTCP_PSFB] + assert messages[0].media_ssrcs == [] + # Any other empty part is malformed. + assert rtp.parse_rtcp(bytes([0x80, rtp.RTCP_SR, 0, 0]) + pli) == [] + + +def test_parse_rtcp_stops_at_garbage() -> None: + """Verify parsing stops at a part with a bad version or length.""" + assert rtp.parse_rtcp(rtp.build_pli(1, 2) + b"\x00" * 8) == rtp.parse_rtcp( + rtp.build_pli(1, 2) + ) + assert rtp.parse_rtcp(rtp.build_pli(1, 2)[:-1]) == [] + + +@pytest.mark.parametrize( + ("data", "parsed"), + [ + (bytes([0x81, 206, 0, 0]) + bytes(8), 0), + (bytes([0x81, 203, 0, 0]), 0), + (bytes([0x80, 203, 0, 0, 0x80, 200, 0, 0]), 1), + ], + ids=["zero-length-pli", "zero-length-bye-with-count", "bye-then-empty-sr"], +) +def test_parse_rtcp_rejects_zero_length(data: bytes, parsed: int) -> None: + """Verify a part without room for the sender SSRC stops parsing.""" + assert len(rtp.parse_rtcp(data)) == parsed + + +@pytest.mark.parametrize( + ("payload_type", "rtcp"), [(96, False), (63, False), (64, True), (72, True)] +) +def test_rtp_packet_is_rtcp(payload_type: int, rtcp: bool) -> None: + """Verify RTCP sent to an RTP port is recognised by its payload type.""" + packet = rtp.RtpPacket(payload_type, 1, 2, 3, False, b"") + assert packet.is_rtcp is rtcp + + +def test_sender_report_layout() -> None: + """Verify the SR fields and length.""" + report = rtp.build_sender_report(9, 0x0102030405060708, 7, 8, 9) + assert len(report) == 28 + assert struct.unpack(">BBHIQIII", report) == ( + 0x80, + 200, + 6, + 9, + 0x0102030405060708, + 7, + 8, + 9, + ) + + +def test_sdes_is_word_aligned() -> None: + """Verify the SDES chunk is padded to 32 bits.""" + sdes = rtp.build_sdes(9, "abc") + assert len(sdes) % 4 == 0 + assert struct.unpack_from(">H", sdes, 2)[0] == len(sdes) // 4 - 1 + + +def test_ntp_time() -> None: + """Verify Unix time maps onto the NTP era.""" + assert rtp.ntp_time(1.5) == (rtp.NTP_EPOCH_OFFSET + 1) << 32 | 1 << 31 diff --git a/tests/test_media_source.py b/tests/test_media_source.py new file mode 100644 index 00000000..0a9088c1 --- /dev/null +++ b/tests/test_media_source.py @@ -0,0 +1,273 @@ +"""Tests for pyhap.media.source: the shared RTP input of WebRTC sessions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +import socket +from types import SimpleNamespace + +import pytest + +from pyhap.media import rtp, source as source_module +from pyhap.media.hevc import AccessUnit, HevcPacketizer +from pyhap.media.source import MediaSource + +VPS, SPS, PPS = bytes([64, 1, 1]), bytes([66, 1, 2]), bytes([68, 1, 3]) +IDR = bytes([19 << 1, 1]) + bytes(3000) +TRAIL = bytes([1 << 1, 1]) + bytes(500) + + +class Consumer: + """Records what a source delivers to one session.""" + + def __init__(self, wants_media: bool = True) -> None: + """Start empty.""" + self.wants_media = wants_media + self.video: list[tuple[AccessUnit, int]] = [] + self.audio: list[rtp.RtpPacket] = [] + self.closed: list[str] = [] + + def on_source_video(self, unit: AccessUnit, ssrc: int) -> None: + """Record an access unit.""" + self.video.append((unit, ssrc)) + + def on_source_audio(self, packet: rtp.RtpPacket) -> None: + """Record an audio packet.""" + self.audio.append(packet) + + def on_source_closed(self, reason: str) -> None: + """Record the end of the source.""" + self.closed.append(reason) + + +class Feeder: + """Builds RTP for the source's input handlers.""" + + def __init__(self, ssrc: int = 1, sequence: int = 100) -> None: + """Start a stream.""" + self.ssrc = ssrc + self.sequence = sequence + self.timestamp = 0 + + def unit(self, nals: list[bytes]) -> list[bytes]: + """Return the RTP packets of one access unit.""" + payloads = HevcPacketizer(1200).packetize(nals) + packets = [] + for index, payload in enumerate(payloads): + last = index == len(payloads) - 1 + packet = rtp.RtpPacket( + 96, self.sequence, self.timestamp, self.ssrc, last, payload + ) + packets.append(rtp.build_rtp(packet)) + self.sequence = (self.sequence + 1) & 0xFFFF + self.timestamp += 3000 + return packets + + +def push(source: MediaSource, packets: list[bytes]) -> None: + """Hand datagrams to the video input.""" + for packet in packets: + source._on_video_input(packet) + + +def units_of(consumer: Consumer) -> list[list[bytes]]: + """Return the NAL units of each delivered access unit.""" + return [unit.nals for unit, _ssrc in consumer.video] + + +@pytest.fixture +async def source() -> AsyncIterator[MediaSource]: + """Yield a started source.""" + started = MediaSource() + await started.start() + yield started + started.close() + + +async def test_fan_out_reassembles_once(source: MediaSource) -> None: + """Verify every attached session gets the same access unit object.""" + first, second = Consumer(), Consumer() + source.attach(first) + source.attach(second) + push(source, Feeder().unit([VPS, SPS, PPS, IDR])) + assert units_of(first) == [[VPS, SPS, PPS, IDR]] + assert first.video[0][0] is second.video[0][0] + assert source.session_count == 2 + + +async def test_no_reassembly_without_a_connected_session(source: MediaSource) -> None: + """Verify input is not reassembled while no session sends, keyframes noted.""" + waiting = Consumer(wants_media=False) + source.attach(waiting) + assert source.keyframe_age() is None + push(source, Feeder().unit([VPS, SPS, PPS, IDR])) + assert waiting.video == [] + assert source.keyframe_age() is not None + assert source.last_video_input > 0 + + +async def test_rtcp_on_rtp_port_is_dropped(source: MediaSource) -> None: + """Verify an RTCP sender report sent to the video port is ignored.""" + consumer = Consumer() + source.attach(consumer) + report = rtp.build_sender_report(1, 0, 0, 0, 0) + source._on_video_input(report) + source._on_audio_input(report) + source._on_video_input(b"") + assert source.last_video_input == 0 + assert consumer.audio == [] + + +async def test_ssrc_change_starts_a_fresh_unit(source: MediaSource) -> None: + """Verify a restarted input (new SSRC) does not merge into the old unit.""" + consumer = Consumer() + source.attach(consumer) + old = Feeder(ssrc=1).unit([IDR]) + push(source, old[:1]) + push(source, Feeder(ssrc=2, sequence=old[0][3] + 1).unit([TRAIL])) + assert units_of(consumer) == [[TRAIL]] + assert consumer.video[0][1] == 2 + + +async def test_pause_keeps_parameter_sets(source: MediaSource) -> None: + """Verify a pause without receivers keeps the cached parameter sets.""" + consumer = Consumer() + source.attach(consumer) + feeder = Feeder() + push(source, feeder.unit([VPS, SPS, PPS, IDR])) + consumer.wants_media = False + push(source, feeder.unit([TRAIL])) + consumer.wants_media = True + push(source, feeder.unit([IDR])) + assert units_of(consumer) == [[VPS, SPS, PPS, IDR], [VPS, SPS, PPS, IDR]] + + +async def test_audio_is_forwarded(source: MediaSource) -> None: + """Verify Opus packets reach connected sessions only.""" + connected, waiting = Consumer(), Consumer(wants_media=False) + source.attach(connected) + source.attach(waiting) + packet = rtp.RtpPacket(111, 1, 960, 7, False, b"opus") + source._on_audio_input(rtp.build_rtp(packet)) + assert connected.audio == [packet] + assert waiting.audio == [] + + +async def test_close_ends_sessions(source: MediaSource) -> None: + """Verify closing the source ends attached sessions with the reason.""" + consumer = Consumer() + reasons: list[str] = [] + source.on_closed = reasons.append + source.attach(consumer) + source.detach(Consumer()) + source.close("ffmpeg exited") + source.close("again") + assert consumer.closed == ["ffmpeg exited"] + assert reasons == ["ffmpeg exited"] + assert source.session_count == 0 + with pytest.raises(RuntimeError, match="closed"): + source.attach(consumer) + with pytest.raises(RuntimeError, match="closed"): + await source.start() + + +async def test_input_ports_and_buffer(source: MediaSource) -> None: + """Verify both ports are bound with an enlarged receive buffer.""" + host, video, audio = source.rtp_input + assert host == "127.0.0.1" + assert video > 0 + assert audio > 0 + assert await source.start() == source.rtp_input + sock = source._sockets[0].get_extra_info("socket") + assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) >= 400_000 + + +async def test_video_only_source() -> None: + """Verify a source without audio binds one port.""" + source = MediaSource(audio=False) + assert (await source.start())[2] == 0 + source.close() + + +async def test_datagrams_reach_the_source(source: MediaSource) -> None: + """Verify real UDP input arrives through the bound socket.""" + consumer = Consumer() + source.attach(consumer) + sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + send_all(sender, source.rtp_input[1], Feeder().unit([VPS, SPS, PPS, IDR])) + await asyncio.wait_for(wait_for_video(consumer), 2) + sender.close() + assert units_of(consumer) == [[VPS, SPS, PPS, IDR]] + + +def send_all(sock: socket.socket, port: int, packets: list[bytes]) -> None: + """Send datagrams to a loopback port.""" + for packet in packets: + sock.sendto(packet, ("127.0.0.1", port)) + + +async def wait_for_video(consumer: Consumer) -> None: + """Wait until an access unit was delivered.""" + while not consumer.video: + await asyncio.sleep(0.01) + + +def feed_gop(source: MediaSource, feeder: Feeder, frames: int) -> None: + """Push one IRAP picture and ``frames - 1`` trailing pictures.""" + push(source, feeder.unit([VPS, SPS, PPS, IDR])) + for _ in range(frames - 1): + push(source, feeder.unit([TRAIL])) + + +async def test_measures_the_irap_interval(source: MediaSource) -> None: + """Verify the GOP comes from the RTP time between IRAP pictures.""" + feeder = Feeder() + feed_gop(source, feeder, 60) + assert source.gop is None + feed_gop(source, feeder, 30) + assert source.gop == 2.0 + push(source, feeder.unit([IDR])) + assert source.gop == 1.75 + + +async def test_irap_interval_restarts_with_a_new_ssrc(source: MediaSource) -> None: + """Verify a restarted input does not count the gap as a GOP.""" + feed_gop(source, Feeder(ssrc=1), 1) + feed_gop(source, Feeder(ssrc=2), 1) + assert source.gop is None + + +def measure(source: MediaSource, size: int, now: float) -> None: + """Measure one video packet of ``size`` payload bytes at ``now``.""" + source._measure(rtp.RtpPacket(96, 1, 0, 1, False, bytes([2, 1]) + bytes(size)), now) + + +async def test_measures_the_video_bitrate(source: MediaSource) -> None: + """Verify the bit rate is measured per window and smoothed.""" + measure(source, 98, 100.0) + measure(source, 998, 100.5) + measure(source, 998, 101.0) + assert source.video_bitrate == 16000 + measure(source, 3998, 102.0) + assert source.video_bitrate == 24000 + + +def pinned_protocol() -> tuple[source_module._InputProtocol, list[bytes]]: + """Return an input protocol and the datagrams it passes on.""" + received: list[bytes] = [] + return source_module._InputProtocol(received.append), received + + +def test_input_is_pinned_to_one_sender(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify a second sender is only taken after the first went silent.""" + clock = iter([10.0, 10.5, 10.9, 12.0, 12.1]) + fake_time = SimpleNamespace(monotonic=lambda: next(clock)) + monkeypatch.setattr(source_module, "time", fake_time) + protocol, received = pinned_protocol() + protocol.datagram_received(b"a1", ("127.0.0.1", 1000)) + protocol.datagram_received(b"b1", ("127.0.0.1", 2000)) + protocol.datagram_received(b"a2", ("127.0.0.1", 1000)) + protocol.datagram_received(b"b2", ("127.0.0.1", 2000)) + protocol.datagram_received(b"a3", ("127.0.0.1", 1000)) + assert received == [b"a1", b"a2", b"b2"] diff --git a/tests/test_media_worker.py b/tests/test_media_worker.py new file mode 100644 index 00000000..e613403f --- /dev/null +++ b/tests/test_media_worker.py @@ -0,0 +1,163 @@ +"""Tests for pyhap.media.worker: the framing and lifecycle of the worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from pyhap.media import _worker # noqa: E402 +from pyhap.media.worker import ( # noqa: E402 + MAX_MESSAGE, + MediaWorker, + encode_message, + read_message, +) + +pytestmark = pytest.mark.timeout(30) + + +@pytest.fixture +async def worker() -> AsyncIterator[MediaWorker]: + """Yield a started worker and stop it afterwards.""" + started = MediaWorker() + await started.start() + yield started + await started.close() + + +def reader_of(data: bytes) -> asyncio.StreamReader: + """A stream reader that yields ``data`` and then EOF.""" + reader = asyncio.StreamReader() + reader.feed_data(data) + reader.feed_eof() + return reader + + +async def test_framing_round_trip() -> None: + """Verify a message and its binary part survive the framing.""" + data = encode_message({"event": "audio", "timestamp": 960}, b"\x00opus") + assert await read_message(reader_of(data)) == ( + {"event": "audio", "timestamp": 960}, + b"\x00opus", + ) + + +async def test_framing_rejects_oversized() -> None: + """Verify neither side accepts a message over the size limit.""" + with pytest.raises(ValueError, match="too large"): + encode_message({}, bytes(MAX_MESSAGE)) + header = (MAX_MESSAGE).to_bytes(4, "big") + (1).to_bytes(4, "big") + with pytest.raises(ValueError, match="too large"): + await read_message(reader_of(header)) + + +async def test_framing_rejects_non_object() -> None: + """Verify a frame must hold a JSON object.""" + data = (2).to_bytes(4, "big") + bytes(4) + b"[]" + with pytest.raises(ValueError, match="object"): + await read_message(reader_of(data)) + + +async def test_lifecycle(worker: MediaWorker) -> None: + """Verify start is idempotent, close stops the process and use restarts it.""" + pid = worker.pid + await worker.start() + assert worker.pid == pid + await worker.close() + assert not worker.running + await worker.close() + await worker.start() + assert worker.running + assert worker.pid != pid + + +def kill(worker: MediaWorker) -> None: + """Crash the worker process.""" + assert worker._runner is not None + worker._runner.kill() + + +class Output: + """Collects what the worker side writes to its output pipe.""" + + def __init__(self, buffered: int = 0) -> None: + """Start empty, with ``buffered`` bytes the parent has not read.""" + self.data = b"" + self.buffered = buffered + self.closed = False + + def is_closing(self) -> bool: + """Whether the pipe was closed.""" + return self.closed + + def get_write_buffer_size(self) -> int: + """The bytes the parent has not read.""" + return self.buffered + + def write(self, data: bytes) -> None: + """Keep the frame.""" + self.data += data + + def close(self) -> None: + """Close the pipe.""" + self.closed = True + + +async def unserializable(message: dict[str, Any]) -> dict[str, Any]: + """A request handler whose result cannot be encoded.""" + return {"sdp": object()} + + +async def test_unencodable_answer_becomes_an_error() -> None: + """Verify the worker answers with an error when its result cannot be sent.""" + output = Output() + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server._op_open_source = unserializable # type: ignore[method-assign] + await server._respond({"id": 7, "op": "open_source"}) + message, _blob = await read_message(reader_of(output.data)) + assert message["id"] == 7 + assert message["error"].startswith("Answer not sent") + + +async def test_slow_parent_loses_log_records_first() -> None: + """Verify a backed up pipe drops log records but still sends events.""" + output = Output(buffered=_worker.LOG_DROP_BUFFER + 1) + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server.send({"event": "log", "level": 30, "name": "pyhap", "message": "x"}) + assert output.data == b"" + server.send({"event": "source_closed", "source": 1, "reason": "x"}) + message, _blob = await read_message(reader_of(output.data)) + assert message["event"] == "source_closed" + + +async def test_stalled_parent_stops_the_worker() -> None: + """Verify the worker gives up on a parent that stopped reading.""" + output = Output(buffered=_worker.MAX_OUTPUT_BUFFER + 1) + server = _worker._Server(output) # ty: ignore[invalid-argument-type] + server.send({"event": "source_closed", "source": 1, "reason": "x"}) + assert output.data == b"" + assert output.closed + assert server.stopping.is_set() + + +async def test_worker_crash_ends_its_sources(worker: MediaWorker) -> None: + """Verify a crashed worker closes its sources and the next use restarts it.""" + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + pid = worker.pid + + kill(worker) + for _ in range(200): + if reasons: + break + await asyncio.sleep(0.01) + + assert source.closed + assert len(reasons) == 1 + again = await worker.open_source() + assert worker.pid != pid + assert not again.closed diff --git a/tests/test_media_worker_thread.py b/tests/test_media_worker_thread.py new file mode 100644 index 00000000..e627ad14 --- /dev/null +++ b/tests/test_media_worker_thread.py @@ -0,0 +1,136 @@ +"""Tests for the thread mode of pyhap.media.worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +import logging +import socket +import threading + +import pytest + +from pyhap.media import _worker +from pyhap.media.worker import THREAD_NAME, MediaWorker, MediaWorkerError + +pytestmark = pytest.mark.timeout(30) + + +@pytest.fixture +async def worker() -> AsyncIterator[MediaWorker]: + """Yield a started worker thread and stop it afterwards.""" + started = MediaWorker(in_thread=True) + await started.start() + yield started + await started.close() + + +def worker_threads() -> list[threading.Thread]: + """The worker threads that still run.""" + return [t for t in threading.enumerate() if t.name == THREAD_NAME] + + +async def wait_until_gone() -> None: + """Wait until no worker thread runs.""" + for _ in range(200): + if not worker_threads(): + return + await asyncio.sleep(0.01) + pytest.fail("worker thread still runs") + + +async def test_lifecycle(worker: MediaWorker) -> None: + """Verify the thread starts once, stops on close and starts again on use.""" + assert worker.in_thread + assert worker.running + assert worker.pid is None + assert len(worker_threads()) == 1 + generation = await worker.start() + assert len(worker_threads()) == 1 + + await worker.close() + assert not worker.running + await wait_until_gone() + await worker.close() + + assert await worker.start() == generation + 1 + assert worker.running + + +async def test_source_round_trip(worker: MediaWorker) -> None: + """Verify a source opens and closes in the thread like in a process.""" + source = await worker.open_source(audio=False) + host, video_port, audio_port = source.rtp_input + assert host == "127.0.0.1" + assert video_port > 0 + assert audio_port == 0 + assert not source.has_audio + await source.close() + assert source.closed + + +async def test_close_ends_sources() -> None: + """Verify closing the worker ends its sources with a reason.""" + worker = MediaWorker(in_thread=True) + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + exits: list[str] = [] + worker.on_exit = exits.append + + await worker.close() + + assert source.closed + assert reasons == [exits[0]] + assert "stopped" in exits[0] + await wait_until_gone() + + +async def test_kill_ends_sources_and_restarts(worker: MediaWorker) -> None: + """Verify a dropped thread ends its sources and the next use starts anew.""" + source = await worker.open_source() + reasons: list[str] = [] + source.on_closed = reasons.append + assert worker._runner is not None + + worker._runner.kill() + for _ in range(200): + if reasons: + break + await asyncio.sleep(0.01) + + assert source.closed + assert len(reasons) == 1 + again = await worker.open_source() + assert not again.closed + assert worker.running + + +async def test_loggers_are_shared(worker: MediaWorker) -> None: + """Verify the thread neither forwards records nor touches logger levels.""" + media = logging.getLogger("pyhap.media") + handlers = list(logging.getLogger("pyhap").handlers) + level = media.level + propagate = logging.getLogger("pyhap").propagate + + await worker.update_log_levels() + await worker.open_source() + + assert logging.getLogger("pyhap").handlers == handlers + assert media.level == level + assert logging.getLogger("pyhap").propagate == propagate + + +async def test_failed_thread_fails_start(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify a worker thread that dies before it is ready fails the start.""" + + async def broken(sock: socket.socket) -> None: + sock.close() + raise RuntimeError("boom") + + monkeypatch.setattr(_worker, "serve_socket", broken) + worker = MediaWorker(in_thread=True, start_timeout=5) + + with pytest.raises(MediaWorkerError): + await worker.start() + assert not worker.running diff --git a/tox.ini b/tox.ini index f245245d..a221cd00 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,6 @@ python = # [project.dependencies] are the single source of truth for runtime deps. extras = QRCode - Talkback deps = -r{toxinidir}/requirements_test.txt commands = @@ -22,7 +21,6 @@ commands = [testenv:codecov] extras = QRCode - Talkback deps = -r{toxinidir}/requirements_test.txt commands = @@ -58,7 +56,6 @@ commands = [testenv:ty] extras = QRCode - Talkback # benchmarks/ is in [tool.ty.src], so the type checker needs its deps too. bench deps =