Compare commits

...

13 Commits

Author SHA1 Message Date
Robert Resch
32f04e6fcc Remove webrtc provider on removing camera entity 2026-01-28 16:14:59 +00:00
Robert Resch
b7905d441c Implement review suggestions 2026-01-28 15:46:04 +00:00
Robert Resch
49f6576106 Update homeassistant/components/camera/__init__.py
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-01-28 16:29:26 +01:00
Robert Resch
da71c6b7e1 Update homeassistant/components/camera/webrtc.py
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-28 16:28:23 +01:00
Robert Resch
ccbff4bdc4 Merge branch 'dev' into edenhaus-go2rtc-preload 2026-01-27 22:52:37 +01:00
Robert Resch
e80c8c1d78 Don't call preload endpoints on each source update 2026-01-27 21:45:19 +00:00
Robert Resch
b5fd8b1923 Add camera tests 2026-01-24 12:47:58 +00:00
Robert Resch
4cc324a608 Add go2rtc tests 2026-01-24 10:13:59 +00:00
Robert Resch
01c8efb518 Merge remote-tracking branch 'origin/dev' into edenhaus-go2rtc-preload 2026-01-24 09:43:21 +00:00
Robert Resch
1fe5dc544d Get preload state from the server 2026-01-22 15:56:24 +00:00
Robert Resch
7583f98b0c Merge branch 'dev' into edenhaus-go2rtc-preload 2026-01-22 16:50:40 +01:00
Robert Resch
dfdfa7fef0 Merge branch 'dev' into edenhaus-go2rtc-preload 2025-12-19 10:47:20 +01:00
Robert Resch
7cd02988a0 Add support for camera preloading to go2rtc 2025-12-09 16:47:22 +01:00
9 changed files with 391 additions and 17 deletions

View File

@@ -234,7 +234,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
@@ -515,6 +515,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
@@ -674,6 +680,13 @@ 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)
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
if self._webrtc_provider:
await self._webrtc_provider.async_unregister_camera(self)
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.
@@ -690,11 +703,19 @@ 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 old_provider.async_unregister_camera(self)
if new_provider:
await new_provider.async_register_camera(self)
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]]
@@ -947,6 +968,10 @@ 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 provider.async_on_camera_prefs_update(camera)
connection.send_result(msg["id"], entity_prefs)

View File

@@ -146,7 +146,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.
## This is an optional method so we need a default here.
async def async_get_image(
self,
@@ -157,6 +157,27 @@ 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."""
## 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."""
## 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."""
## This is an optional method so we need a default here.
@callback
def async_register_webrtc_provider(

View File

@@ -413,12 +413,53 @@ class WebRTCProvider(CameraWebRTCProvider):
],
)
async def _update_preload_stream(self, camera: Camera) -> None:
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 == (camera.entity_id 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(camera.entity_id)
else:
await self._rest_client.preload.disable(camera.entity_id)
async def teardown(self) -> None:
"""Tear down the provider."""
for ws_client in self._sessions.values():
await ws_client.close()
self._sessions.clear()
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)
async def async_unregister_camera(
self,
camera: Camera,
) -> None:
"""Will be called when the provider is unregistered for a camera."""
streams = await self._rest_client.streams.list()
if streams.get(camera.entity_id):
# If no stream exists, no need to disable preload
# as a stream is required to enable preload
await self._update_preload_stream(camera)
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:

View File

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

View File

@@ -37,7 +37,7 @@ 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
@@ -1027,3 +1027,101 @@ 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(
hass: HomeAssistant,
) -> None:
"""Test that register and unregister are called when provider support changes."""
await async_setup_component(hass, "camera", {})
await hass.async_block_till_done()
# Register provider
provider = SomeTestProvider()
async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera_obj._webrtc_provider is provider
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")
async def test_camera_prefs_update_calls_provider_callback(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that async_on_camera_prefs_update is called when prefs are updated."""
await async_setup_component(hass, "camera", {})
await hass.async_block_till_done()
# Register test provider
await _register_test_webrtc_provider(hass)
camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera_obj._webrtc_provider
# 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)

View File

@@ -707,13 +707,43 @@ 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")
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
# Mock the async_unregister_camera method
with patch.object(
register_test_provider, "async_unregister_camera", autospec=True
) as mock_unregister:
# Call async_internal_will_remove_from_hass directly to test the cleanup logic
await camera.async_internal_will_remove_from_hass()
# Verify async_unregister_camera was called with the camera
mock_unregister.assert_called_once_with(camera)
# Verify the provider reference was cleared
assert camera._webrtc_provider is None

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,7 @@ def rest_client() -> Generator[AsyncMock]:
return_value=AwesomeVersion(RECOMMENDED_VERSION)
)
client.webrtc = Mock(spec_set=_WebRTCClient)
client.preload = Mock(spec_set=_PreloadClient)
yield client

View File

@@ -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({
}),

View File

@@ -62,6 +62,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."""
@@ -837,13 +851,9 @@ async def _test_camera_orientation(
# 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
await _setup_camera_prefs(hass, camera.entity_id, test_settings)
# Call the camera function that should trigger stream update
await camera_fn(hass, camera)
@@ -1173,3 +1183,145 @@ 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")
async def test_preload_not_enabled_when_preference_disabled(
hass: HomeAssistant,
rest_client: AsyncMock,
init_test_integration: MockCamera,
) -> None:
"""Test preload is not enabled when camera has no preload preference."""
camera = init_test_integration
test_settings = DynamicStreamSettings(
orientation=Orientation.NO_TRANSFORM, preload_stream=False
)
await _setup_camera_prefs(hass, camera.entity_id, test_settings)
await camera.async_handle_async_webrtc_offer(OFFER_SDP, "session_id", Mock())
# Verify preload was not enabled
rest_client.preload.enable.assert_not_called()
@pytest.mark.usefixtures("init_integration", "ws_client")
async def test_preload_disabled_on_unregister(
rest_client: AsyncMock,
init_test_integration: MockCamera,
) -> None:
"""Test async_unregister_camera disables preload when stream exists."""
camera = init_test_integration
assert isinstance(camera._webrtc_provider, WebRTCProvider)
provider = camera._webrtc_provider
rest_client.streams.list.return_value = {
camera.entity_id: Stream([Producer("rtsp://stream")])
}
rest_client.preload.list.return_value = {camera.entity_id}
await provider.async_unregister_camera(camera)
# Verify preload was disabled
rest_client.preload.disable.assert_called_once_with(camera.entity_id)
@pytest.mark.usefixtures("init_integration", "ws_client")
async def test_preload_not_disabled_when_no_stream_exists(
rest_client: AsyncMock,
init_test_integration: MockCamera,
) -> None:
"""Test async_unregister_camera doesn't disable preload when no stream exists."""
camera = init_test_integration
assert isinstance(camera._webrtc_provider, WebRTCProvider)
provider = camera._webrtc_provider
await provider.async_unregister_camera(camera)
# Verify preload was not disabled since no stream exists
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
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(camera.entity_id)
rest_client.preload.disable.assert_not_called()
# Now disable preload preference
rest_client.preload.list.return_value = {camera.entity_id}
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(camera.entity_id)
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 = {camera.entity_id}
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()