Add support for stream orientation in go2rtc (#148832)

This commit is contained in:
Robert Resch
2025-09-01 20:06:01 +02:00
committed by GitHub
parent 095f73d84f
commit 0865d3f749
5 changed files with 337 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
)