mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 09:03:13 +01:00
Add support for stream orientation in go2rtc (#148832)
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
76
tests/components/camera/test_prefs.py
Normal file
76
tests/components/camera/test_prefs.py
Normal 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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user