From 819759f7797e4758ba794d531d380777988fecac Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 29 Jul 2026 14:40:49 +0200 Subject: [PATCH] Add support for camera preloading to go2rtc (#158470) Co-authored-by: Martin Hjelmare Co-authored-by: Erwin Douna --- homeassistant/components/camera/__init__.py | 69 ++++- homeassistant/components/camera/webrtc.py | 14 +- homeassistant/components/go2rtc/__init__.py | 43 ++++ homeassistant/components/go2rtc/server.py | 1 + tests/components/camera/test_init.py | 239 ++++++++++++++---- tests/components/camera/test_webrtc.py | 27 +- tests/components/go2rtc/conftest.py | 9 +- .../go2rtc/snapshots/test_server.ambr | 4 +- tests/components/go2rtc/test_init.py | 189 +++++++++++++- 9 files changed, 524 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 00c245380dee..557ed76408d3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -231,7 +231,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if (provider := camera._webrtc_provider) and ( # noqa: SLF001 + if (provider := camera.webrtc_provider) and ( image := await provider.async_get_image(camera, width=width, height=height) ) is not None: return image @@ -407,6 +407,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) +async def _async_call_webrtc_provider( + coro: Coroutine[Any, Any, None], description: str, entity_id: str +) -> None: + """Await a WebRTC provider callback without letting exceptions propagate. + + Provider callbacks can do I/O and must not break camera setup or removal. + """ + try: + await coro + except HomeAssistantError as ex: + _LOGGER.error("Error %s %s: %s", description, entity_id, ex) + except Exception: + _LOGGER.exception("Unexpected error %s %s", description, entity_id) + + CACHED_PROPERTIES_WITH_ATTR_ = { "brand", "frame_interval", @@ -514,6 +529,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return False return super().available + @final + @property + def webrtc_provider(self) -> CameraWebRTCProvider | None: + """Return the WebRTC provider.""" + return self._webrtc_provider + async def async_create_stream(self) -> Stream | None: """Create a Stream for stream_source.""" # There is at most one stream (a decode worker) per camera @@ -683,6 +704,18 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM await self.async_refresh_providers(write_state=False) + @override + async def async_internal_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._webrtc_provider: + await _async_call_webrtc_provider( + self._webrtc_provider.async_unregister_camera(self), + "unregistering WebRTC provider for", + self.entity_id, + ) + self._webrtc_provider = None + await super().async_internal_will_remove_from_hass() + async def async_refresh_providers(self, *, write_state: bool = True) -> None: """Determine if any of the registered providers are suitable for this entity. @@ -699,11 +732,27 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async_get_supported_provider ) - if old_provider != new_provider: - self._webrtc_provider = new_provider - self._invalidate_camera_capabilities_cache() - if write_state: - self.async_write_ha_state() + if old_provider == new_provider: + return + + if old_provider: + await _async_call_webrtc_provider( + old_provider.async_unregister_camera(self), + "unregistering WebRTC provider for", + self.entity_id, + ) + + if new_provider: + await _async_call_webrtc_provider( + new_provider.async_register_camera(self), + "registering WebRTC provider for", + self.entity_id, + ) + + self._webrtc_provider = new_provider + self._invalidate_camera_capabilities_cache() + if write_state: + self.async_write_ha_state() async def _async_get_supported_webrtc_provider[_T]( self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] @@ -969,6 +1018,14 @@ async def websocket_update_prefs( _LOGGER.error("Error setting camera preferences: %s", ex) connection.send_error(msg["id"], "update_failed", str(ex)) else: + if (camera := hass.data[DATA_COMPONENT].get_entity(entity_id)) and ( + provider := camera.webrtc_provider + ): + await _async_call_webrtc_provider( + provider.async_on_camera_prefs_update(camera), + "notifying WebRTC provider of preferences update for", + entity_id, + ) connection.send_result(msg["id"], entity_prefs) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 6ac905c8f049..64cf62119b20 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -145,7 +145,7 @@ class CameraWebRTCProvider(ABC): @callback def async_close_session(self, session_id: str) -> None: """Close the session.""" - return ## This is an optional method so we need a default here. + return # This is an optional method so we need a default here. async def async_get_image( self, @@ -156,6 +156,18 @@ class CameraWebRTCProvider(ABC): """Get an image from the camera.""" return None + async def async_register_camera(self, camera: Camera) -> None: + """Will be called when the provider is registered for a camera.""" + return # This is an optional method so we need a default here. + + async def async_unregister_camera(self, camera: Camera) -> None: + """Will be called when the provider is unregistered for a camera.""" + return # This is an optional method so we need a default here. + + async def async_on_camera_prefs_update(self, camera: Camera) -> None: + """Will be called when the camera preferences are updated.""" + return # This is an optional method so we need a default here. + @callback def async_register_webrtc_provider( diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4b921c085bbb..3c736aa03f1d 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -423,12 +423,55 @@ class WebRTCProvider(CameraWebRTCProvider): ], ) + async def _update_preload_stream(self, camera: Camera) -> None: + identifier = get_camera_identifier(camera) + camera_prefs = await get_dynamic_camera_stream_settings( + self._hass, camera.entity_id + ) + preload_streams = await self._rest_client.preload.list() + + if camera_prefs.preload_stream == (identifier in preload_streams): + return + + if camera_prefs.preload_stream: + # We need to first add the stream source otherwise preload enabling will fail + await self._update_stream_source(camera) + await self._rest_client.preload.enable(identifier) + else: + await self._rest_client.preload.disable(identifier) + async def teardown(self) -> None: """Tear down the provider.""" for ws_client in self._sessions.values(): await ws_client.close() self._sessions.clear() + @override + async def async_register_camera( + self, + camera: Camera, + ) -> None: + """Will be called when the provider is registered for a camera.""" + await self._update_preload_stream(camera) + + @override + async def async_unregister_camera( + self, + camera: Camera, + ) -> None: + """Will be called when the provider is unregistered for a camera.""" + identifier = get_camera_identifier(camera) + if identifier in await self._rest_client.preload.list(): + await self._rest_client.preload.disable(identifier) + + @override + async def async_on_camera_prefs_update( + self, + camera: Camera, + ) -> None: + """Will be called when the camera preferences are updated.""" + await self._update_preload_stream(camera) + @dataclass class Go2RtcConfig: diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 08af88f0929c..e386d0677b23 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -74,6 +74,7 @@ _API_ALLOW_PATHS = ( "/", # UI static page and version control "/api", # Main API path "/api/frame.jpeg", # Snapshot functionality + "/api/preload", # Preload functionality "/api/schemes", # Supported stream schemes "/api/streams", # Stream management "/api/webrtc", # Webrtc functionality diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 060a5cf77b3a..6b7618a6f418 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -8,16 +8,9 @@ from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch from aiohttp import hdrs import pytest from syrupy.assertion import SnapshotAssertion -from webrtc_models import RTCIceCandidateInit from homeassistant.components import camera -from homeassistant.components.camera import ( - Camera, - CameraWebRTCProvider, - WebRTCAnswer, - WebRTCSendMessage, - async_register_webrtc_provider, -) +from homeassistant.components.camera import Camera, async_register_webrtc_provider from homeassistant.components.camera.const import ( DOMAIN, PREF_ORIENTATION, @@ -31,14 +24,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, SomeTestProvider, mock_turbo_jpeg from tests.common import async_fire_time_changed from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -53,6 +46,21 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.fixture +async def register_provider_and_get_camera( + hass: HomeAssistant, +) -> tuple[Camera, Callable[[], None]]: + """Fixture for mock camera.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Register test provider + unsub = await _register_test_webrtc_provider(hass) + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + assert camera_obj.webrtc_provider is not None + return camera_obj, unsub + + @pytest.mark.usefixtures("image_mock_url") async def test_get_image_from_camera(hass: HomeAssistant) -> None: """Grab an image from camera entity.""" @@ -856,34 +864,6 @@ async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) - async def _register_test_webrtc_provider(hass: HomeAssistant) -> Callable[[], None]: - class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - @property - def domain(self) -> str: - """Return domain.""" - return "test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return True - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer.""" - send_message(WebRTCAnswer("answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidateInit - ) -> None: - """Handle the WebRTC candidate.""" - provider = SomeTestProvider() unsub = async_register_webrtc_provider(hass, provider) await hass.async_block_till_done() @@ -960,7 +940,7 @@ async def test_webrtc_provider_not_added_for_native_webrtc( """Test that a WebRTC provider is not added for native WebRTC.""" camera_obj = get_camera_from_entity_id(hass, "camera.async") assert camera_obj - assert camera_obj._webrtc_provider is None + assert camera_obj.webrtc_provider is None assert camera_obj._supports_native_async_webrtc is True @@ -1014,21 +994,18 @@ async def test_camera_capabilities_changing_native_support( @pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_snapshot_service_webrtc_provider( hass: HomeAssistant, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], ) -> None: """Test snapshot service with the webrtc provider.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - unsub = await _register_test_webrtc_provider(hass) - camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") - assert camera_obj._webrtc_provider + camera_obj, unsub = register_provider_and_get_camera with ( patch.object(camera_obj, "use_stream_for_stills", return_value=True), patch("homeassistant.components.camera.open"), patch.object( - camera_obj._webrtc_provider, + camera_obj.webrtc_provider, "async_get_image", - wraps=camera_obj._webrtc_provider.async_get_image, + wraps=camera_obj.webrtc_provider.async_get_image, ) as webrtc_get_image_mock, patch.object(camera_obj, "stream", AsyncMock()) as stream_mock, patch( @@ -1073,7 +1050,7 @@ async def test_snapshot_service_webrtc_provider( # Deregister provider unsub() await hass.async_block_till_done() - assert camera_obj._webrtc_provider is None + assert camera_obj.webrtc_provider is None webrtc_get_image_mock.reset_mock() stream_mock.reset_mock() @@ -1088,3 +1065,171 @@ async def test_snapshot_service_webrtc_provider( ) stream_mock.async_get_image.assert_called_once() webrtc_get_image_mock.assert_not_called() + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_provider_change_register_unregister_called( + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], +) -> None: + """Test that register and unregister are called when provider support changes.""" + camera_obj, _ = register_provider_and_get_camera + provider = camera_obj.webrtc_provider + assert isinstance(provider, SomeTestProvider) + + with ( + patch.object( + provider, "async_unregister_camera", AsyncMock() + ) as mock_unregister, + patch.object(provider, "async_register_camera", AsyncMock()) as mock_register, + ): + # Make provider unsupported + provider._is_supported = False + await camera_obj.async_refresh_providers() + assert camera_obj.webrtc_provider is None + + # Verify unregister was called + mock_unregister.assert_called_once_with(camera_obj) + mock_register.assert_not_called() + + # Make provider supported again + mock_unregister.reset_mock() + provider._is_supported = True + await camera_obj.async_refresh_providers() + assert camera_obj.webrtc_provider is provider + + # Verify register was called + mock_register.assert_called_once_with(camera_obj) + mock_unregister.assert_not_called() + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_provider_register_error_does_not_propagate( + hass: HomeAssistant, + side_effect: Exception, +) -> None: + """Test a failing register callback does not prevent provider assignment.""" + provider = SomeTestProvider() + with patch.object( + provider, "async_register_camera", AsyncMock(side_effect=side_effect) + ) as mock_register: + async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + mock_register.assert_any_call(camera_obj) + assert camera_obj.webrtc_provider is provider + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_provider_unregister_error_does_not_propagate( + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], + side_effect: Exception, +) -> None: + """Test a failing unregister callback does not break camera removal.""" + camera_obj, _ = register_provider_and_get_camera + + with patch.object( + camera_obj.webrtc_provider, + "async_unregister_camera", + AsyncMock(side_effect=side_effect), + ) as mock_unregister: + await camera_obj.async_remove() + + mock_unregister.assert_called_once_with(camera_obj) + assert camera_obj.webrtc_provider is None + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_prefs_update_calls_provider_callback( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], +) -> None: + """Test that async_on_camera_prefs_update is called when prefs are updated.""" + camera_obj, _ = register_provider_and_get_camera + # Patch the callback method + with patch.object( + camera_obj.webrtc_provider, + "async_on_camera_prefs_update", + AsyncMock(), + ) as mock_prefs_update: + # Update camera preferences through WebSocket + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": True, + } + ) + msg = await client.receive_json() + + # Assert preference was updated + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is True + + # Verify callback was called + mock_prefs_update.assert_called_once_with(camera_obj) + + # Update another preference + mock_prefs_update.reset_mock() + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": False, + } + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is False + + # Verify callback was called again + mock_prefs_update.assert_called_once_with(camera_obj) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_camera_prefs_update_provider_callback_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], + side_effect: Exception, +) -> None: + """Test prefs update succeeds even if the provider callback raises.""" + camera_obj, _ = register_provider_and_get_camera + + with patch.object( + camera_obj.webrtc_provider, + "async_on_camera_prefs_update", + AsyncMock(side_effect=side_effect), + ) as mock_prefs_update: + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": True, + } + ) + msg = await client.receive_json() + + # The preferences are persisted despite the provider callback failing + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is True + mock_prefs_update.assert_called_once_with(camera_obj) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 6c389c791f0d..d9bb9e966878 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -721,13 +721,38 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: ) -> None: """Handle the WebRTC candidate.""" + camera = Mock() provider = OnlyRequiredInterfaceProvider() # Call all interface methods assert provider.async_is_supported("stream_source") is True await provider.async_handle_async_webrtc_offer( - Mock(), "offer_sdp", "session_id", Mock() + camera, "offer_sdp", "session_id", Mock() ) await provider.async_on_webrtc_candidate( "session_id", RTCIceCandidateInit("candidate") ) provider.async_close_session("session_id") + # Call optional methods to verify they do not raise exceptions + await provider.async_register_camera(camera) + await provider.async_unregister_camera(camera) + await provider.async_on_camera_prefs_update(camera) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_unregisters_from_webrtc_provider_on_removal( + hass: HomeAssistant, + register_test_provider: SomeTestProvider, +) -> None: + """Test camera unregisters from WebRTC provider when removed from hass.""" + camera = get_camera_from_entity_id(hass, "camera.demo_camera") + + # Verify the provider is registered + assert camera.webrtc_provider is not None + assert camera.webrtc_provider == register_test_provider + + with patch.object( + register_test_provider, "async_unregister_camera", autospec=True + ) as mock_unregister: + await camera.async_remove() + mock_unregister.assert_called_once_with(camera) + assert camera.webrtc_provider is None diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 12292a75221d..41d2f03031f1 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -5,7 +5,12 @@ from pathlib import Path from unittest.mock import AsyncMock, Mock, patch from awesomeversion import AwesomeVersion -from go2rtc_client.rest import _SchemesClient, _StreamClient, _WebRTCClient +from go2rtc_client.rest import ( + _PreloadClient, + _SchemesClient, + _StreamClient, + _WebRTCClient, +) import pytest from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -63,6 +68,8 @@ def rest_client() -> Generator[AsyncMock]: return_value=AwesomeVersion(RECOMMENDED_VERSION) ) client.webrtc = Mock(spec_set=_WebRTCClient) + client.preload = preload = Mock(spec_set=_PreloadClient) + preload.list.return_value = {} yield client diff --git a/tests/components/go2rtc/snapshots/test_server.ambr b/tests/components/go2rtc/snapshots/test_server.ambr index 61f5115e8ea2..e39b297b5f0a 100644 --- a/tests/components/go2rtc/snapshots/test_server.ambr +++ b/tests/components/go2rtc/snapshots/test_server.ambr @@ -3,7 +3,7 @@ _CallList([ _Call( tuple( - b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n local_auth: true\n username: d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1\n password: bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/preload","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n local_auth: true\n username: d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1\n password: bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', ), dict({ }), @@ -14,7 +14,7 @@ _CallList([ _Call( tuple( - b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n local_auth: true\n username: user\n password: pass\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/preload","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n local_auth: true\n username: user\n password: pass\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', ), dict({ }), diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 64096cdb08c6..4a3208321438 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -65,6 +65,20 @@ OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." +async def _setup_camera_prefs( + hass: HomeAssistant, + entity_id: str, + settings: DynamicStreamSettings, +) -> CameraPreferences: + """Set up camera preferences with optional orientation and preload_stream.""" + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + prefs._dynamic_stream_settings_by_entity_id[entity_id] = settings + return prefs + + @pytest.fixture(name="has_go2rtc_entry") def has_go2rtc_entry_fixture() -> bool: """Fixture to control if a go2rtc config entry should be created.""" @@ -163,9 +177,9 @@ async def _test_setup_and_signaling( await test("session_3") rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) - provider = camera._webrtc_provider + provider = camera.webrtc_provider for session in sessions: assert session in provider._sessions @@ -798,12 +812,12 @@ async def test_async_get_image( ) -> None: """Test getting snapshot from go2rtc.""" camera = init_test_integration - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) rest_client.get_jpeg_snapshot.return_value = image_bytes - assert await camera._webrtc_provider.async_get_image(camera) == image_bytes + assert await camera.webrtc_provider.async_get_image(camera) == image_bytes image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes @@ -824,7 +838,7 @@ async def test_generic_workaround( ) -> None: """Test workaround for generic integration cameras.""" camera = init_test_integration - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) @@ -855,16 +869,11 @@ async def _test_camera_orientation( ) -> None: """Test camera orientation handling in go2rtc provider.""" # Ensure go2rtc provider is initialized - assert isinstance(camera._webrtc_provider, WebRTCProvider) + 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 + # 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 + await _setup_camera_prefs(hass, camera.entity_id, test_settings) # Call the camera function that should trigger stream update await camera_fn(hass, camera) @@ -1198,3 +1207,157 @@ async def test_basic_auth_with_debug_ui(hass: HomeAssistant, server_dir: Path) - call_kwargs = mock_server_cls.call_args[1] assert call_kwargs["username"] == "test_user" assert call_kwargs["password"] == "test_pass" + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize("preload", [True, False]) +async def test_preload_settings_is_applied_on_register( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + preload: bool, +) -> None: + """Test preload settings are applied when camera is registered.""" + camera = init_test_integration + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=preload + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + provider = camera.webrtc_provider + await provider.async_register_camera(camera) + if preload: + rest_client.preload.enable.assert_called_once_with( + get_camera_identifier(camera) + ) + else: + rest_client.preload.enable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_disabled_on_unregister( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test async_unregister_camera disables preload when it is enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + identifier = get_camera_identifier(camera) + rest_client.preload.list.return_value = {identifier} + # The preference stays enabled, but go2rtc must not keep preloading a + # camera the provider no longer handles + await _setup_camera_prefs( + hass, + camera.entity_id, + DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ), + ) + + await provider.async_unregister_camera(camera) + + rest_client.preload.disable.assert_called_once_with(identifier) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_not_disabled_when_not_enabled( + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test async_unregister_camera doesn't disable preload when it is not enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + + await provider.async_unregister_camera(camera) + + rest_client.preload.disable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_toggle_on_preference_update( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload is toggled when camera preferences are updated.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + identifier = get_camera_identifier(camera) + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ) + prefs = await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload was enabled + rest_client.preload.enable.assert_called_once_with(identifier) + rest_client.preload.disable.assert_not_called() + + # Now disable preload preference + rest_client.preload.list.return_value = {identifier} + rest_client.preload.enable.reset_mock() + rest_client.preload.disable.reset_mock() + + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=False + ) + prefs._dynamic_stream_settings_by_entity_id[camera.entity_id] = test_settings + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload was disabled + rest_client.preload.disable.assert_called_once_with(identifier) + rest_client.preload.enable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_no_change_when_already_enabled( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload enable is not called when already enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + rest_client.preload.list.return_value = {get_camera_identifier(camera)} + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload enable/disable were not called + rest_client.preload.enable.assert_not_called() + rest_client.preload.disable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_no_change_when_already_disabled( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload disable is not called when already disabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=False + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload enable/disable were not called + rest_client.preload.enable.assert_not_called() + rest_client.preload.disable.assert_not_called()