mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 14:02:03 +01:00
Compare commits
5 Commits
setpoint_c
...
homekit-au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc26288ab | ||
|
|
a694845756 | ||
|
|
75bd7f5eb9 | ||
|
|
c2456281bc | ||
|
|
8a76c51675 |
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user