diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 62c72688d85..7748f86b9ac 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -11,6 +11,7 @@ "requirements": [ "HAP-python==5.0.0", "fnv-hash-fast==2.0.0", + "homekit-audio-proxy==1.2.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index cb5de026501..87802bf1661 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -6,6 +6,7 @@ import logging from typing import Any from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg +from homekit_audio_proxy import AudioProxy from pyhap.camera import ( VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, @@ -89,11 +90,10 @@ AUDIO_OUTPUT = ( "{a_application}" "-ac 1 -ar {a_sample_rate}k " "-b:a {a_max_bitrate}k -bufsize {a_bufsize}k " + "{a_frame_duration}" "-payload_type 110 " "-ssrc {a_ssrc} -f rtp " - "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} " - "srtp://{address}:{a_port}?rtcpport={a_port}&" - "localrtpport={a_port}&pkt_size={a_pkt_size}" + "rtp://127.0.0.1:{a_proxy_port}?pkt_size={a_pkt_size}" ) SLOW_RESOLUTIONS = [ @@ -120,6 +120,7 @@ FFMPEG_WATCH_INTERVAL = timedelta(seconds=5) FFMPEG_LOGGER = "ffmpeg_logger" FFMPEG_WATCHER = "ffmpeg_watcher" FFMPEG_PID = "ffmpeg_pid" +AUDIO_PROXY = "audio_proxy" SESSION_ID = "session_id" CONFIG_DEFAULTS = { @@ -339,8 +340,33 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] + " " ) audio_application = "" + audio_frame_duration = "" if self.config[CONF_AUDIO_CODEC] == "libopus": audio_application = "-application lowdelay " + audio_frame_duration = ( + f"-frame_duration {stream_config.get('a_packet_time', 20)} " + ) + # Start audio proxy to convert Opus RTP timestamps from 48kHz + # (FFmpeg's hardcoded Opus RTP clock rate per RFC 7587) to the + # sample rate negotiated by HomeKit (typically 16kHz). + # a_sample_rate is in kHz (e.g. 16 for 16000 Hz) from pyhap TLV. + audio_proxy: AudioProxy | None = None + if self.config[CONF_SUPPORT_AUDIO]: + audio_proxy = AudioProxy( + dest_addr=stream_config["address"], + dest_port=stream_config["a_port"], + srtp_key_b64=stream_config["a_srtp_key"], + target_clock_rate=stream_config["a_sample_rate"] * 1000, + ) + await audio_proxy.async_start() + if not audio_proxy.local_port: + _LOGGER.error( + "[%s] Audio proxy failed to start", + self.display_name, + ) + await audio_proxy.async_stop() + audio_proxy = None + output_vars = stream_config.copy() output_vars.update( { @@ -354,6 +380,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] "a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE], "a_encoder": self.config[CONF_AUDIO_CODEC], "a_application": audio_application, + "a_frame_duration": audio_frame_duration, + "a_proxy_port": audio_proxy.local_port if audio_proxy else 0, } ) output = VIDEO_OUTPUT.format(**output_vars) @@ -371,6 +399,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] ) if not opened: _LOGGER.error("Failed to open ffmpeg stream") + if audio_proxy: + await audio_proxy.async_stop() return False _LOGGER.debug( @@ -381,6 +411,7 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] session_info["stream"] = stream session_info[FFMPEG_PID] = stream.process.pid + session_info[AUDIO_PROXY] = audio_proxy stderr_reader = await stream.get_reader(source=FFMPEG_STDERR) @@ -441,6 +472,9 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] async def stop_stream(self, session_info: dict[str, Any]) -> None: """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] + if proxy := session_info.pop(AUDIO_PROXY, None): + await proxy.async_stop() + if not (stream := session_info.get("stream")): _LOGGER.debug("No stream for session ID %s", session_id) return diff --git a/requirements_all.txt b/requirements_all.txt index d7229400da9..97cc729cdad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1231,6 +1231,9 @@ home-assistant-frontend==20260312.0 # homeassistant.components.conversation home-assistant-intents==2026.3.3 +# homeassistant.components.homekit +homekit-audio-proxy==1.2.1 + # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1f33c1d0f1..0f78156e386 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1092,6 +1092,9 @@ home-assistant-frontend==20260312.0 # homeassistant.components.conversation home-assistant-intents==2026.3.3 +# homeassistant.components.homekit +homekit-audio-proxy==1.2.1 + # homeassistant.components.gentex_homelink homelink-integration-api==0.0.1 diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 6dad8a83461..37b76f6ff10 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -46,6 +46,7 @@ from homeassistant.util import dt as dt_util from tests.components.camera.common import mock_turbo_jpeg +MOCK_AUDIO_PROXY_PORT = 23456 MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" MOCK_END_POINTS_TLV = "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" MOCK_START_STREAM_SESSION_UUID = UUID("3303d503-17cc-469a-b672-92436a71a2f6") @@ -59,6 +60,21 @@ async def setup_homeassistant(hass: HomeAssistant) -> None: await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture(autouse=True) +def mock_audio_proxy(): + """Mock AudioProxy to avoid spawning real subprocesses.""" + mock_proxy = MagicMock() + mock_proxy.local_port = MOCK_AUDIO_PROXY_PORT + mock_proxy.async_start = AsyncMock() + mock_proxy.async_stop = AsyncMock() + + with patch( + "homeassistant.components.homekit.type_cameras.AudioProxy", + return_value=mock_proxy, + ): + yield mock_proxy + + async def _async_start_streaming(hass: HomeAssistant, acc: Camera) -> None: """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) @@ -206,10 +222,9 @@ async def test_camera_stream_source_configured(hass: HomeAssistant, run_driver) "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " - "-vn -c:a libopus -application lowdelay -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type " - "110 -ssrc {a_ssrc} -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " - "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" + "-vn -c:a libopus -application lowdelay -ac 1 -ar 24k -b:a 24k -bufsize 96k " + f"-frame_duration 20 -payload_type 110 -ssrc {{a_ssrc}} -f rtp " + f"rtp://127.0.0.1:{MOCK_AUDIO_PROXY_PORT}?pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -322,6 +337,52 @@ async def test_camera_stream_source_configured_with_failing_ffmpeg( await _async_stop_all_streams(hass, acc) +async def test_camera_stream_source_audio_proxy_fails( + hass: HomeAssistant, run_driver, mock_audio_proxy: MagicMock +) -> None: + """Test streaming continues without audio when audio proxy fails to start.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + + entity_id = "camera.demo_camera" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + {CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True}, + ) + + acc.run() + await _async_setup_endpoints(hass, acc) + + # Simulate audio proxy failing to bind a port + mock_audio_proxy.local_port = 0 + working_ffmpeg = _get_working_mock_ffmpeg() + + with ( + patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=None, + ), + patch( + "homeassistant.components.homekit.type_cameras.HAFFmpeg", + return_value=working_ffmpeg, + ), + ): + await _async_start_streaming(hass, acc) + await _async_stop_all_streams(hass, acc) + + mock_audio_proxy.async_stop.assert_called() + + async def test_camera_stream_source_found(hass: HomeAssistant, run_driver) -> None: """Test a camera that can stream and we get the source from the entity.""" await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) @@ -540,10 +601,9 @@ async def test_camera_stream_source_configured_and_copy_codec( "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " - "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " - "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " - "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" + "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k " + f"-payload_type 110 -ssrc {{a_ssrc}} -f rtp " + f"rtp://127.0.0.1:{MOCK_AUDIO_PROXY_PORT}?pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -616,10 +676,9 @@ async def test_camera_stream_source_configured_and_override_profile_names( "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " - "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " - "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " - "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" + "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k " + f"-payload_type 110 -ssrc {{a_ssrc}} -f rtp " + f"rtp://127.0.0.1:{MOCK_AUDIO_PROXY_PORT}?pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -693,10 +752,9 @@ async def test_camera_streaming_fails_after_starting_ffmpeg( "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " - "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " - "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " - "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" + "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k " + f"-payload_type 110 -ssrc {{a_ssrc}} -f rtp " + f"rtp://127.0.0.1:{MOCK_AUDIO_PROXY_PORT}?pkt_size=188" ) ffmpeg_with_invalid_pid.open.assert_called_with(