Add support for camera preloading to go2rtc (#158470)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
This commit is contained in:
Robert Resch
2026-07-29 14:40:49 +02:00
committed by GitHub
co-authored by Martin Hjelmare Erwin Douna
parent d6032ba2a4
commit 819759f779
9 changed files with 524 additions and 71 deletions
+63 -6
View File
@@ -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)
+13 -1
View File
@@ -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(
@@ -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:
@@ -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
+192 -47
View File
@@ -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)
+26 -1
View File
@@ -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
+8 -1
View File
@@ -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
@@ -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({
}),
+176 -13
View File
@@ -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()