Create repair issue for legacy webrtc provider (#129334)

* Add repair issue

* Add tests

* Add option to not use builtin go2rtc provider

* Add test

* Add domain to new providers

* Add learn more url

* Update placeholder

* Promote the builtin provider

* Refactor provider storage

* Move check for legacy provider conflict to refresh

* Test provider registration race

* Add test for registering the same legacy provider twice

* Test test_get_not_supported_legacy_provider

* Remove blank line between bullets

* Call it built-in

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Revert "Add option to not use builtin go2rtc provider"

This reverts commit 4e31bad6c0c23d5a1c0935c985351808a46163d6.

* Revert "Add test"

This reverts commit ddf85fd4db2c78b15c1cdc716804b965f3a1f4e3.

* Update issue description

* async_close_session is optional

* Clean up after rebase

* Add required domain property to provider tests

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Martin Hjelmare 2024-10-30 14:11:17 +01:00 committed by GitHub
parent b4e69bab71
commit 405a480cae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 197 additions and 45 deletions

View File

@ -46,6 +46,10 @@
} }
} }
} }
},
"legacy_webrtc_provider": {
"title": "Detected use of legacy WebRTC provider registered by {legacy_integration}",
"description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant."
} }
}, },
"services": { "services": {

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
@ -15,7 +16,7 @@ from webrtc_models import RTCConfiguration, RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid from homeassistant.util.ulid import ulid
@ -31,7 +32,7 @@ _LOGGER = logging.getLogger(__name__)
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey( DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_webrtc_providers" "camera_webrtc_providers"
) )
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[set[CameraWebRTCLegacyProvider]] = HassKey( DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey(
"camera_webrtc_legacy_providers" "camera_webrtc_legacy_providers"
) )
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey( DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
@ -113,13 +114,20 @@ class WebRTCClientConfiguration:
return data return data
class CameraWebRTCProvider(Protocol): class CameraWebRTCProvider(ABC):
"""WebRTC provider.""" """WebRTC provider."""
@property
@abstractmethod
def domain(self) -> str:
"""Return the integration domain of the provider."""
@callback @callback
@abstractmethod
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source.""" """Determine if the provider supports the stream source."""
@abstractmethod
async def async_handle_async_webrtc_offer( async def async_handle_async_webrtc_offer(
self, self,
camera: Camera, camera: Camera,
@ -129,6 +137,7 @@ class CameraWebRTCProvider(Protocol):
) -> None: ) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
@abstractmethod
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
"""Handle the WebRTC candidate.""" """Handle the WebRTC candidate."""
@ -150,10 +159,10 @@ class CameraWebRTCLegacyProvider(Protocol):
"""Handle the WebRTC offer and return an answer.""" """Handle the WebRTC offer and return an answer."""
def _async_register_webrtc_provider[_T]( @callback
def async_register_webrtc_provider(
hass: HomeAssistant, hass: HomeAssistant,
key: HassKey[set[_T]], provider: CameraWebRTCProvider,
provider: _T,
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register a WebRTC provider. """Register a WebRTC provider.
@ -162,7 +171,7 @@ def _async_register_webrtc_provider[_T](
if DOMAIN not in hass.data: if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded") raise ValueError("Unexpected state, camera not loaded")
providers = hass.data.setdefault(key, set()) providers = hass.data.setdefault(DATA_WEBRTC_PROVIDERS, set())
@callback @callback
def remove_provider() -> None: def remove_provider() -> None:
@ -177,20 +186,9 @@ def _async_register_webrtc_provider[_T](
return remove_provider return remove_provider
@callback
def async_register_webrtc_provider(
hass: HomeAssistant,
provider: CameraWebRTCProvider,
) -> Callable[[], None]:
"""Register a WebRTC provider.
The first provider to satisfy the offer will be used.
"""
return _async_register_webrtc_provider(hass, DATA_WEBRTC_PROVIDERS, provider)
async def _async_refresh_providers(hass: HomeAssistant) -> None: async def _async_refresh_providers(hass: HomeAssistant) -> None:
"""Check all cameras for any state changes for registered providers.""" """Check all cameras for any state changes for registered providers."""
_async_check_conflicting_legacy_provider(hass)
component = hass.data[DATA_COMPONENT] component = hass.data[DATA_COMPONENT]
await asyncio.gather( await asyncio.gather(
@ -334,11 +332,11 @@ def async_register_ws(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_candidate) websocket_api.async_register_command(hass, ws_candidate)
async def _async_get_supported_provider[ async def async_get_supported_provider(
_T: CameraWebRTCLegacyProvider | CameraWebRTCProvider hass: HomeAssistant, camera: Camera
](hass: HomeAssistant, camera: Camera, key: HassKey[set[_T]]) -> _T | None: ) -> CameraWebRTCProvider | None:
"""Return the first supported provider for the camera.""" """Return the first supported provider for the camera."""
providers = hass.data.get(key) providers = hass.data.get(DATA_WEBRTC_PROVIDERS)
if not providers or not (stream_source := await camera.stream_source()): if not providers or not (stream_source := await camera.stream_source()):
return None return None
@ -349,20 +347,19 @@ async def _async_get_supported_provider[
return None return None
async def async_get_supported_provider(
hass: HomeAssistant, camera: Camera
) -> CameraWebRTCProvider | None:
"""Return the first supported provider for the camera."""
return await _async_get_supported_provider(hass, camera, DATA_WEBRTC_PROVIDERS)
async def async_get_supported_legacy_provider( async def async_get_supported_legacy_provider(
hass: HomeAssistant, camera: Camera hass: HomeAssistant, camera: Camera
) -> CameraWebRTCLegacyProvider | None: ) -> CameraWebRTCLegacyProvider | None:
"""Return the first supported provider for the camera.""" """Return the first supported provider for the camera."""
return await _async_get_supported_provider( providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)
hass, camera, DATA_WEBRTC_LEGACY_PROVIDERS if not providers or not (stream_source := await camera.stream_source()):
) return None
for provider in providers.values():
if await provider.async_is_supported(stream_source):
return provider
return None
@callback @callback
@ -425,7 +422,49 @@ def async_register_rtsp_to_web_rtc_provider(
The first provider to satisfy the offer will be used. The first provider to satisfy the offer will be used.
""" """
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")
legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {})
if domain in legacy_providers:
raise ValueError("Provider already registered")
provider_instance = _CameraRtspToWebRTCProvider(provider) provider_instance = _CameraRtspToWebRTCProvider(provider)
return _async_register_webrtc_provider(
hass, DATA_WEBRTC_LEGACY_PROVIDERS, provider_instance @callback
) def remove_provider() -> None:
legacy_providers.pop(domain)
hass.async_create_task(_async_refresh_providers(hass))
legacy_providers[domain] = provider_instance
hass.async_create_task(_async_refresh_providers(hass))
return remove_provider
@callback
def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None:
"""Check if a legacy provider is registered together with the builtin provider."""
builtin_provider_domain = "go2rtc"
if (
(legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS))
and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS))
and any(provider.domain == builtin_provider_domain for provider in providers)
):
for domain in legacy_providers:
ir.async_create_issue(
hass,
DOMAIN,
f"legacy_webrtc_provider_{domain}",
is_fixable=False,
is_persistent=False,
issue_domain=domain,
learn_more_url="https://www.home-assistant.io/integrations/go2rtc/",
severity=ir.IssueSeverity.WARNING,
translation_key="legacy_webrtc_provider",
translation_placeholders={
"legacy_integration": domain,
"builtin_integration": builtin_provider_domain,
},
)

View File

@ -172,6 +172,11 @@ class WebRTCProvider(CameraWebRTCProvider):
self._rest_client = Go2RtcRestClient(self._session, url) self._rest_client = Go2RtcRestClient(self._session, url)
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return DOMAIN
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source.""" """Return if this provider is supports the Camera as source."""

View File

@ -938,6 +938,11 @@ async def _test_capabilities(
class SomeTestProvider(CameraWebRTCProvider): class SomeTestProvider(CameraWebRTCProvider):
"""Test provider.""" """Test provider."""
@property
def domain(self) -> str:
"""Return domain."""
return "test"
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source.""" """Determine if the provider supports the stream source."""

View File

@ -20,6 +20,7 @@ from homeassistant.components.camera import (
WebRTCError, WebRTCError,
WebRTCMessage, WebRTCMessage,
WebRTCSendMessage, WebRTCSendMessage,
async_get_supported_legacy_provider,
async_register_ice_servers, async_register_ice_servers,
async_register_rtsp_to_web_rtc_provider, async_register_rtsp_to_web_rtc_provider,
async_register_webrtc_provider, async_register_webrtc_provider,
@ -30,6 +31,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.core_config import async_process_ha_core_config from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import STREAM_SOURCE, WEBRTC_ANSWER from .common import STREAM_SOURCE, WEBRTC_ANSWER
@ -49,13 +51,18 @@ HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
TEST_INTEGRATION_DOMAIN = "test" TEST_INTEGRATION_DOMAIN = "test"
class TestProvider(CameraWebRTCProvider): class SomeTestProvider(CameraWebRTCProvider):
"""Test provider.""" """Test provider."""
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the provider.""" """Initialize the provider."""
self._is_supported = True self._is_supported = True
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "some_test"
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source.""" """Determine if the provider supports the stream source."""
@ -82,6 +89,15 @@ class TestProvider(CameraWebRTCProvider):
"""Close the session.""" """Close the session."""
class Go2RTCProvider(SomeTestProvider):
"""go2rtc provider."""
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "go2rtc"
class MockCamera(Camera): class MockCamera(Camera):
"""Mock Camera Entity.""" """Mock Camera Entity."""
@ -162,11 +178,13 @@ async def init_test_integration(
@pytest.fixture @pytest.fixture
async def register_test_provider(hass: HomeAssistant) -> AsyncGenerator[TestProvider]: async def register_test_provider(
hass: HomeAssistant,
) -> AsyncGenerator[SomeTestProvider]:
"""Add WebRTC test provider.""" """Add WebRTC test provider."""
await async_setup_component(hass, "camera", {}) await async_setup_component(hass, "camera", {})
provider = TestProvider() provider = SomeTestProvider()
unsub = async_register_webrtc_provider(hass, provider) unsub = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done() await hass.async_block_till_done()
yield provider yield provider
@ -183,7 +201,7 @@ async def test_async_register_webrtc_provider(
camera = get_camera_from_entity_id(hass, "camera.demo_camera") camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera.frontend_stream_type is StreamType.HLS assert camera.frontend_stream_type is StreamType.HLS
provider = TestProvider() provider = SomeTestProvider()
unregister = async_register_webrtc_provider(hass, provider) unregister = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -211,7 +229,7 @@ async def test_async_register_webrtc_provider(
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") @pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_webrtc_provider_twice( async def test_async_register_webrtc_provider_twice(
hass: HomeAssistant, hass: HomeAssistant,
register_test_provider: TestProvider, register_test_provider: SomeTestProvider,
) -> None: ) -> None:
"""Test registering a WebRTC provider twice should raise.""" """Test registering a WebRTC provider twice should raise."""
with pytest.raises(ValueError, match="Provider already registered"): with pytest.raises(ValueError, match="Provider already registered"):
@ -223,7 +241,7 @@ async def test_async_register_webrtc_provider_camera_not_loaded(
) -> None: ) -> None:
"""Test registering a WebRTC provider when camera is not loaded.""" """Test registering a WebRTC provider when camera is not loaded."""
with pytest.raises(ValueError, match="Unexpected state, camera not loaded"): with pytest.raises(ValueError, match="Unexpected state, camera not loaded"):
async_register_webrtc_provider(hass, TestProvider()) async_register_webrtc_provider(hass, SomeTestProvider())
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") @pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
@ -494,7 +512,7 @@ async def test_websocket_webrtc_offer(
async def test_websocket_webrtc_offer_webrtc_provider( async def test_websocket_webrtc_offer_webrtc_provider(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
register_test_provider: TestProvider, register_test_provider: SomeTestProvider,
message: WebRTCMessage, message: WebRTCMessage,
expected_frontend_message: dict[str, Any], expected_frontend_message: dict[str, Any],
) -> None: ) -> None:
@ -997,7 +1015,7 @@ async def test_ws_webrtc_candidate_not_supported(
async def test_ws_webrtc_candidate_webrtc_provider( async def test_ws_webrtc_candidate_webrtc_provider(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
register_test_provider: TestProvider, register_test_provider: SomeTestProvider,
) -> None: ) -> None:
"""Test ws webrtc candidate command with WebRTC provider.""" """Test ws webrtc candidate command with WebRTC provider."""
with patch.object( with patch.object(
@ -1045,7 +1063,7 @@ async def test_ws_webrtc_candidate_invalid_entity(
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_camera_webrtc")
async def test_ws_webrtc_canidate_missing_candidtae( async def test_ws_webrtc_canidate_missing_candidate(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
"""Test ws WebRTC candidate command with missing required fields.""" """Test ws WebRTC candidate command with missing required fields."""
@ -1094,6 +1112,11 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
class OnlyRequiredInterfaceProvider(CameraWebRTCProvider): class OnlyRequiredInterfaceProvider(CameraWebRTCProvider):
"""Test provider.""" """Test provider."""
@property
def domain(self) -> str:
"""Return the domain of the provider."""
return "test"
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source.""" """Determine if the provider supports the stream source."""
@ -1125,3 +1148,79 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
) )
await provider.async_on_webrtc_candidate("session_id", "candidate") await provider.async_on_webrtc_candidate("session_id", "candidate")
provider.async_close_session("session_id") provider.async_close_session("session_id")
@pytest.mark.usefixtures("mock_camera")
async def test_repair_issue_legacy_provider(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repair issue created for legacy provider."""
# Ensure no issue if no provider is registered
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
# Register a legacy provider
legacy_provider = Mock(side_effect=provide_webrtc_answer)
unsub_legacy_provider = async_register_rtsp_to_web_rtc_provider(
hass, "mock_domain", legacy_provider
)
await hass.async_block_till_done()
# Ensure no issue if only legacy provider is registered
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
provider = Go2RTCProvider()
unsub_go2rtc_provider = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
# Ensure issue when legacy and builtin provider are registered
issue = issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
assert issue
assert issue.is_fixable is False
assert issue.is_persistent is False
assert issue.issue_domain == "mock_domain"
assert issue.learn_more_url == "https://www.home-assistant.io/integrations/go2rtc/"
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.issue_id == "legacy_webrtc_provider_mock_domain"
assert issue.translation_key == "legacy_webrtc_provider"
assert issue.translation_placeholders == {
"legacy_integration": "mock_domain",
"builtin_integration": "go2rtc",
}
unsub_legacy_provider()
unsub_go2rtc_provider()
@pytest.mark.usefixtures("mock_camera", "register_test_provider", "mock_rtsp_to_webrtc")
async def test_no_repair_issue_without_new_provider(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repair issue not created if no go2rtc provider exists."""
assert not issue_registry.async_get_issue(
"camera", "legacy_webrtc_provider_mock_domain"
)
@pytest.mark.usefixtures("mock_camera", "mock_rtsp_to_webrtc")
async def test_registering_same_legacy_provider(
hass: HomeAssistant,
) -> None:
"""Test registering the same legacy provider twice."""
legacy_provider = Mock(side_effect=provide_webrtc_answer)
with pytest.raises(ValueError, match="Provider already registered"):
async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", legacy_provider)
@pytest.mark.usefixtures("mock_hls_stream_source", "mock_camera", "mock_rtsp_to_webrtc")
async def test_get_not_supported_legacy_provider(hass: HomeAssistant) -> None:
"""Test getting a not supported legacy provider."""
camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert await async_get_supported_legacy_provider(hass, camera) is None