Compare commits

...

5 Commits

Author SHA1 Message Date
J. Nick Koston
8bc26288ab Bump homekit-audio-proxy to 1.2.1
https://github.com/bdraco/homekit-audio-proxy/releases/tag/v1.2.1
2026-03-09 00:14:40 -10:00
J. Nick Koston
a694845756 Add test for audio proxy start failure path 2026-03-08 23:58:38 -10:00
J. Nick Koston
75bd7f5eb9 Update camera tests for homekit-audio-proxy integration 2026-03-08 23:52:02 -10:00
J. Nick Koston
c2456281bc Merge branch 'dev' into homekit-audio-proxy-fix 2026-03-08 23:46:19 -10:00
J. Nick Koston
8a76c51675 Fix choppy HomeKit camera audio with SRTP audio proxy
Closes #118135
2026-03-08 23:44:12 -10:00
5 changed files with 118 additions and 19 deletions

View File

@@ -11,6 +11,7 @@
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==1.6.0",
"homekit-audio-proxy==1.2.1",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -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

3
requirements_all.txt generated
View File

@@ -1228,6 +1228,9 @@ home-assistant-frontend==20260304.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

View File

@@ -1089,6 +1089,9 @@ home-assistant-frontend==20260304.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

View File

@@ -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(