From 0865d3f749e11305485ea1540ba7aa6a7decdedd Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 1 Sep 2025 20:06:01 +0200 Subject: [PATCH] Add support for stream orientation in go2rtc (#148832) --- homeassistant/components/camera/__init__.py | 16 +- homeassistant/components/camera/prefs.py | 11 +- homeassistant/components/go2rtc/__init__.py | 31 ++- tests/components/camera/test_prefs.py | 76 +++++++ tests/components/go2rtc/test_init.py | 213 +++++++++++++++++++- 5 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 tests/components/camera/test_prefs.py diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4286e7462cc..b54cca05c22 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -81,7 +81,11 @@ from .const import ( ) from .helper import get_camera_from_entity_id from .img_util import scale_jpeg_camera_image -from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 +from .prefs import ( + CameraPreferences, + DynamicStreamSettings, # noqa: F401 + get_dynamic_camera_stream_settings, +) from .webrtc import ( DATA_ICE_SERVERS, CameraWebRTCProvider, @@ -550,9 +554,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.hass, source, options=self.stream_options, - dynamic_stream_settings=await self.hass.data[ - DATA_CAMERA_PREFS - ].get_dynamic_stream_settings(self.entity_id), + dynamic_stream_settings=await get_dynamic_camera_stream_settings( + self.hass, self.entity_id + ), stream_label=self.entity_id, ) self.stream.set_update_callback(self.async_write_ha_state) @@ -942,9 +946,7 @@ async def websocket_get_prefs( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle request for account info.""" - stream_prefs = await hass.data[DATA_CAMERA_PREFS].get_dynamic_stream_settings( - msg["entity_id"] - ) + stream_prefs = await get_dynamic_camera_stream_settings(hass, msg["entity_id"]) connection.send_result(msg["id"], asdict(stream_prefs)) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 2eccaf500e1..ceeb050b899 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -13,7 +13,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .const import DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM +from .const import DATA_CAMERA_PREFS, DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM STORAGE_KEY: Final = DOMAIN STORAGE_VERSION: Final = 1 @@ -106,3 +106,12 @@ class CameraPreferences: ) self._dynamic_stream_settings_by_entity_id[entity_id] = settings return settings + + +async def get_dynamic_camera_stream_settings( + hass: HomeAssistant, entity_id: str +) -> DynamicStreamSettings: + """Get dynamic stream settings for a camera entity.""" + if DATA_CAMERA_PREFS not in hass.data: + raise HomeAssistantError("Camera integration not set up") + return await hass.data[DATA_CAMERA_PREFS].get_dynamic_stream_settings(entity_id) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index aeedb847090..5ee449f3833 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -31,7 +31,9 @@ from homeassistant.components.camera import ( WebRTCSendMessage, async_register_webrtc_provider, ) +from homeassistant.components.camera.prefs import get_dynamic_camera_stream_settings from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN +from homeassistant.components.stream import Orientation from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -57,12 +59,13 @@ from .server import Server _LOGGER = logging.getLogger(__name__) +_FFMPEG = "ffmpeg" _SUPPORTED_STREAMS = frozenset( ( "bubble", "dvrip", "expr", - "ffmpeg", + _FFMPEG, "gopro", "homekit", "http", @@ -315,6 +318,32 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") + camera_prefs = await get_dynamic_camera_stream_settings( + self._hass, camera.entity_id + ) + if camera_prefs.orientation is not Orientation.NO_TRANSFORM: + # Camera orientation manually set by user + if not stream_source.startswith(_FFMPEG): + stream_source = _FFMPEG + ":" + stream_source + stream_source += "#video=h264#audio=copy" + match camera_prefs.orientation: + case Orientation.MIRROR: + stream_source += "#raw=-vf hflip" + case Orientation.ROTATE_180: + stream_source += "#rotate=180" + case Orientation.FLIP: + stream_source += "#raw=-vf vflip" + case Orientation.ROTATE_LEFT_AND_FLIP: + # Cannot use any filter when using raw one + stream_source += "#raw=-vf transpose=2,vflip" + case Orientation.ROTATE_LEFT: + stream_source += "#rotate=-90" + case Orientation.ROTATE_RIGHT_AND_FLIP: + # Cannot use any filter when using raw one + stream_source += "#raw=-vf transpose=1,vflip" + case Orientation.ROTATE_RIGHT: + stream_source += "#rotate=90" + streams = await self._rest_client.streams.list() if (stream := streams.get(camera.entity_id)) is None or not any( diff --git a/tests/components/camera/test_prefs.py b/tests/components/camera/test_prefs.py new file mode 100644 index 00000000000..e4b3e67f15d --- /dev/null +++ b/tests/components/camera/test_prefs.py @@ -0,0 +1,76 @@ +"""Test camera helper functions.""" + +import pytest + +from homeassistant.components.camera.const import DATA_CAMERA_PREFS +from homeassistant.components.camera.prefs import ( + CameraPreferences, + DynamicStreamSettings, + get_dynamic_camera_stream_settings, +) +from homeassistant.components.stream import Orientation +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +async def test_get_dynamic_camera_stream_settings_missing_prefs( + hass: HomeAssistant, +) -> None: + """Test get_dynamic_camera_stream_settings when camera prefs are not set up.""" + with pytest.raises(HomeAssistantError, match="Camera integration not set up"): + await get_dynamic_camera_stream_settings(hass, "camera.test") + + +async def test_get_dynamic_camera_stream_settings_success(hass: HomeAssistant) -> None: + """Test successful retrieval of dynamic camera stream settings.""" + # Set up camera preferences + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Test with default settings + settings = await get_dynamic_camera_stream_settings(hass, "camera.test") + assert settings.orientation == Orientation.NO_TRANSFORM + assert settings.preload_stream is False + + +async def test_get_dynamic_camera_stream_settings_with_custom_orientation( + hass: HomeAssistant, +) -> None: + """Test get_dynamic_camera_stream_settings with custom orientation set.""" + # Set up camera preferences + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Set custom orientation - this requires entity registry + # For this test, we'll directly manipulate the internal state + # since entity registry setup is complex for a unit test + test_settings = DynamicStreamSettings( + orientation=Orientation.ROTATE_LEFT, preload_stream=False + ) + prefs._dynamic_stream_settings_by_entity_id["camera.test"] = test_settings + + settings = await get_dynamic_camera_stream_settings(hass, "camera.test") + assert settings.orientation == Orientation.ROTATE_LEFT + assert settings.preload_stream is False + + +async def test_get_dynamic_camera_stream_settings_with_preload_stream( + hass: HomeAssistant, +) -> None: + """Test get_dynamic_camera_stream_settings with preload stream enabled.""" + # Set up camera preferences + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Set preload stream by directly setting the dynamic stream settings + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ) + prefs._dynamic_stream_settings_by_entity_id["camera.test"] = test_settings + + settings = await get_dynamic_camera_stream_settings(hass, "camera.test") + assert settings.orientation == Orientation.NO_TRANSFORM + assert settings.preload_stream is True diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index e77e61346b6..7b748096ca5 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,6 +1,6 @@ """The tests for the go2rtc component.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging from typing import NamedTuple from unittest.mock import AsyncMock, Mock, patch @@ -29,6 +29,11 @@ from homeassistant.components.camera import ( WebRTCSendMessage, async_get_image, ) +from homeassistant.components.camera.const import DATA_CAMERA_PREFS +from homeassistant.components.camera.prefs import ( + CameraPreferences, + DynamicStreamSettings, +) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider from homeassistant.components.go2rtc.const import ( @@ -37,6 +42,7 @@ from homeassistant.components.go2rtc.const import ( DOMAIN, RECOMMENDED_VERSION, ) +from homeassistant.components.stream import Orientation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL from homeassistant.exceptions import HomeAssistantError @@ -696,3 +702,208 @@ async def test_generic_workaround( f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", ], ) + + +async def _test_camera_orientation( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, + camera_fn: Callable[[HomeAssistant, MockCamera], Awaitable[None]], +) -> None: + """Test camera orientation handling in go2rtc provider.""" + # Ensure go2rtc provider is initialized + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Set the specific orientation for this test by directly setting the dynamic stream settings + test_settings = DynamicStreamSettings(orientation=orientation, preload_stream=False) + prefs._dynamic_stream_settings_by_entity_id[camera.entity_id] = test_settings + + # Call the camera function that should trigger stream update + await camera_fn(hass, camera) + + # Verify the stream was configured correctly + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + expected_stream_source, + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], + ) + + +async def _test_camera_orientation_webrtc( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, +) -> None: + """Test camera orientation handling in go2rtc provider on WebRTC stream.""" + + async def camera_fn(hass: HomeAssistant, camera: MockCamera) -> None: + """Mock function to simulate WebRTC offer handling.""" + receive_message_callback = Mock() + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "test_session", receive_message_callback + ) + + await _test_camera_orientation( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + camera_fn, + ) + + +async def _test_camera_orientation_get_image( + hass: HomeAssistant, + camera: MockCamera, + orientation: Orientation, + rest_client: AsyncMock, + expected_stream_source: str, +) -> None: + """Test camera orientation handling in go2rtc provider on get_image.""" + + async def camera_fn(hass: HomeAssistant, camera: MockCamera) -> None: + """Mock function to simulate get_image handling.""" + rest_client.get_jpeg_snapshot.return_value = b"image_bytes" + # Get image which should trigger stream update with orientation + await async_get_image(hass, camera.entity_id) + + await _test_camera_orientation( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + camera_fn, + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + ("orientation", "expected_stream_source"), + [ + ( + Orientation.MIRROR, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf hflip", + ), + ( + Orientation.ROTATE_180, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=180", + ), + (Orientation.FLIP, "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf vflip"), + ( + Orientation.ROTATE_LEFT_AND_FLIP, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf transpose=2,vflip", + ), + ( + Orientation.ROTATE_LEFT, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=-90", + ), + ( + Orientation.ROTATE_RIGHT_AND_FLIP, + "ffmpeg:rtsp://stream#video=h264#audio=copy#raw=-vf transpose=1,vflip", + ), + ( + Orientation.ROTATE_RIGHT, + "ffmpeg:rtsp://stream#video=h264#audio=copy#rotate=90", + ), + (Orientation.NO_TRANSFORM, "rtsp://stream"), + ], +) +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + orientation: Orientation, + expected_stream_source: str, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider applies correct orientation filters.""" + camera = init_test_integration + + await test_fn( + hass, + camera, + orientation, + rest_client, + expected_stream_source, + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation_stream_source_starts_ffmpeg( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider applies correct orientation filters when a stream source already starts with ffmpeg.""" + camera = init_test_integration + camera.set_stream_source("ffmpeg:rtsp://test.stream") + + await test_fn( + hass, + camera, + Orientation.ROTATE_LEFT, + rest_client, + "ffmpeg:rtsp://test.stream#video=h264#audio=copy#rotate=-90", + ) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize( + "test_fn", + [ + _test_camera_orientation_webrtc, + _test_camera_orientation_get_image, + ], +) +async def test_stream_orientation_with_generic_camera( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + test_fn: Callable[ + [HomeAssistant, MockCamera, Orientation, AsyncMock, str], Awaitable[None] + ], +) -> None: + """Test WebRTC provider with orientation and generic camera platform.""" + camera = init_test_integration + camera.set_stream_source("https://test.stream/video.m3u8") + + # Test WebRTC offer handling with generic platform + with patch.object(camera.platform.platform_data, "platform_name", "generic"): + await test_fn( + hass, + camera, + Orientation.FLIP, + rest_client, + "ffmpeg:https://test.stream/video.m3u8#video=h264#audio=copy#raw=-vf vflip", + )