Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Skip adding providers if the camera has native WebRTC #129808

Merged
merged 7 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions homeassistant/components/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,13 @@ def __init__(self) -> None:
self._create_stream_lock: asyncio.Lock | None = None
self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._webrtc_sync_offer = (
self._supports_native_sync_webrtc = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
)
self._supports_native_async_webrtc = (
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)

@cached_property
def entity_picture(self) -> str:
Expand Down Expand Up @@ -623,7 +627,7 @@ async def async_handle_async_webrtc_offer(
Integrations can override with a native WebRTC implementation.
"""
if self._webrtc_sync_offer:
if self._supports_native_sync_webrtc:
try:
answer = await self.async_handle_web_rtc_offer(offer_sdp)
except ValueError as ex:
Expand Down Expand Up @@ -788,18 +792,25 @@ async def async_refresh_providers(self, *, write_state: bool = True) -> None:
providers or inputs to the state attributes change.
"""
old_provider = self._webrtc_provider
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)

old_legacy_provider = self._legacy_webrtc_provider
new_provider = None
new_legacy_provider = None
if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider

# Skip all providers if the camera has a native WebRTC implementation
if not (
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
):
# Camera doesn't have a native WebRTC implementation
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)

if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
)

if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
self._webrtc_provider = new_provider
self._legacy_webrtc_provider = new_legacy_provider
Expand Down Expand Up @@ -827,7 +838,7 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration()

if not self._webrtc_sync_offer:
if not self._supports_native_sync_webrtc:
# Until 2024.11, the frontend was not resolving any ice servers
# The async approach was added 2024.11 and new integrations need to use it
ice_servers = [
Expand Down Expand Up @@ -867,12 +878,7 @@ def camera_capabilities(self) -> CameraCapabilities:
"""Return the camera capabilities."""
frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat:
if (
type(self).async_handle_web_rtc_offer
!= Camera.async_handle_web_rtc_offer
or type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
):
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
# The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC)
else:
Expand Down
50 changes: 50 additions & 0 deletions tests/components/camera/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@

from unittest.mock import Mock

from webrtc_models import RTCIceCandidate

from homeassistant.components.camera import (
Camera,
CameraWebRTCProvider,
WebRTCAnswer,
WebRTCSendMessage,
)
from homeassistant.core import callback

EMPTY_8_6_JPEG = b"empty_8_6"
WEBRTC_ANSWER = "a=sendonly"
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
Expand All @@ -23,3 +33,43 @@ def mock_turbo_jpeg(
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG
return mocked_turbo_jpeg


class SomeTestProvider(CameraWebRTCProvider):
"""Test provider."""

def __init__(self) -> None:
"""Initialize the provider."""
self._is_supported = True

@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "some_test"

@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return self._is_supported

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 via the provided callback.

Return value determines if the offer was handled successfully.
"""
send_message(WebRTCAnswer(answer="answer"))

async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate."""

@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""
49 changes: 42 additions & 7 deletions tests/components/camera/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import AsyncMock, Mock, PropertyMock, patch

import pytest
from webrtc_models import RTCIceCandidate

from homeassistant.components import camera
from homeassistant.components.camera.const import StreamType
Expand All @@ -14,7 +15,7 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.setup import async_setup_component

from .common import STREAM_SOURCE, WEBRTC_ANSWER
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider

from tests.common import (
MockConfigEntry,
Expand Down Expand Up @@ -155,16 +156,15 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]:


@pytest.fixture
async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None:
"""Initialize a test camera with native sync WebRTC support."""
async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
"""Initialize a test WebRTC cameras."""

# Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer
# and native support is checked by verify the function "async_handle_web_rtc_offer" was
# overwritten(implemented) or not
class MockCamera(camera.Camera):
"""Mock Camera Entity."""
class BaseCamera(camera.Camera):
"""Base Camera."""

_attr_name = "Test"
_attr_supported_features: camera.CameraEntityFeature = (
camera.CameraEntityFeature.STREAM
)
Expand All @@ -173,9 +173,30 @@ class MockCamera(camera.Camera):
async def stream_source(self) -> str | None:
return STREAM_SOURCE

class SyncCamera(BaseCamera):
"""Mock Camera with native sync WebRTC support."""

_attr_name = "Sync"

async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
return WEBRTC_ANSWER

class AsyncCamera(BaseCamera):
"""Mock Camera with native async WebRTC support."""

_attr_name = "Async"

async def async_handle_async_webrtc_offer(
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))

async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle a WebRTC candidate."""
# Do nothing

domain = "test"

entry = MockConfigEntry(domain=domain)
Expand Down Expand Up @@ -208,10 +229,24 @@ async def async_unload_entry_init(
),
)
setup_test_component_platform(
hass, camera.DOMAIN, [MockCamera()], from_config_entry=True
hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True
)
mock_platform(hass, f"{domain}.config_flow", Mock())

with mock_config_flow(domain, ConfigFlow):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()


@pytest.fixture
async def register_test_provider(
hass: HomeAssistant,
) -> AsyncGenerator[SomeTestProvider]:
"""Add WebRTC test provider."""
await async_setup_component(hass, "camera", {})

provider = SomeTestProvider()
unsub = camera.async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
yield provider
unsub()
20 changes: 18 additions & 2 deletions tests/components/camera/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,13 +979,29 @@ async def test_camera_capabilities_hls(
)


@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer")
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_camera_capabilities_webrtc(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test WebRTC camera capabilities."""

await _test_capabilities(
hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
)


@pytest.mark.parametrize(
("entity_id", "expect_native_async_webrtc"),
[("camera.sync", False), ("camera.async", True)],
)
@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider")
async def test_webrtc_provider_not_added_for_native_webrtc(
hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool
) -> None:
"""Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support."""
camera_obj = get_camera_from_entity_id(hass, entity_id)
assert camera_obj
assert camera_obj._webrtc_provider is None
assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc
assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc
58 changes: 2 additions & 56 deletions tests/components/camera/test_webrtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component

from .common import STREAM_SOURCE, WEBRTC_ANSWER
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider

from tests.common import (
MockConfigEntry,
Expand All @@ -51,46 +51,6 @@
TEST_INTEGRATION_DOMAIN = "test"


class SomeTestProvider(CameraWebRTCProvider):
"""Test provider."""

def __init__(self) -> None:
"""Initialize the provider."""
self._is_supported = True

@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "some_test"

@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return self._is_supported

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 via the provided callback.

Return value determines if the offer was handled successfully.
"""
send_message(WebRTCAnswer(answer="answer"))

async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate."""

@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""


class Go2RTCProvider(SomeTestProvider):
"""go2rtc provider."""

Expand Down Expand Up @@ -179,20 +139,6 @@ async def async_unload_entry_init(
return test_camera


@pytest.fixture
async def register_test_provider(
hass: HomeAssistant,
) -> AsyncGenerator[SomeTestProvider]:
"""Add WebRTC test provider."""
await async_setup_component(hass, "camera", {})

provider = SomeTestProvider()
unsub = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
yield provider
unsub()


@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_webrtc_provider(
hass: HomeAssistant,
Expand Down Expand Up @@ -403,7 +349,7 @@ async def test_ws_get_client_config_sync_offer(

client = await hass_ws_client(hass)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"}
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"}
)
msg = await client.receive_json()

Expand Down
Loading