Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f13aef0547 Extend WebRTCProvider for HLS support and move view to web.py
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
2025-08-20 12:32:14 +00:00
copilot-swe-agent[bot]
9f9e7701c1 Add comprehensive tests for go2rtc HLS integration
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
2025-08-20 12:05:10 +00:00
copilot-swe-agent[bot]
227ed7b8fc Add go2rtc HLS provider implementation
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
2025-08-20 12:02:45 +00:00
copilot-swe-agent[bot]
aa3a760e68 Initial plan 2025-08-20 11:50:12 +00:00
6 changed files with 330 additions and 3 deletions

View File

@@ -1085,6 +1085,23 @@ async def async_handle_play_stream_service(
async def _async_stream_endpoint_url(
hass: HomeAssistant, camera: Camera, fmt: str
) -> str:
# Check if go2rtc HLS provider is available and supports this camera
if fmt == "hls":
try:
from homeassistant.components.go2rtc.const import DOMAIN as GO2RTC_DOMAIN
if (
GO2RTC_DOMAIN in hass.data
and "hls_provider" in hass.data[GO2RTC_DOMAIN]
):
hls_provider = hass.data[GO2RTC_DOMAIN]["hls_provider"]
# Check if camera stream source is compatible with go2rtc
if await camera.stream_source():
if hls_provider.async_is_supported(await camera.stream_source()):
return await hls_provider.async_get_stream_url(camera)
except Exception:
# If go2rtc HLS fails, fall back to stream integration
pass
stream = await camera.async_create_stream()
if not stream:
raise HomeAssistantError(

View File

@@ -50,10 +50,12 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
GO2RTC_HLS_PROVIDER,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
from .server import Server
from .web import Go2RtcHlsView
_LOGGER = logging.getLogger(__name__)
@@ -193,14 +195,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
webrtc_provider = WebRTCProvider(hass, url, session, client)
# Set up HLS support in the WebRTC provider
await webrtc_provider.async_setup_hls()
entry.runtime_data = webrtc_provider
entry.async_on_unload(async_register_webrtc_provider(hass, webrtc_provider))
# Store provider for access in camera integration
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["hls_provider"] = webrtc_provider
return True
async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
"""Unload a go2rtc config entry."""
await entry.runtime_data.teardown()
# Clean up HLS provider reference
if DOMAIN in hass.data and "hls_provider" in hass.data[DOMAIN]:
del hass.data[DOMAIN]["hls_provider"]
return True
@@ -210,7 +227,7 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
"""WebRTC provider with HLS support."""
def __init__(
self,
@@ -225,6 +242,12 @@ class WebRTCProvider(CameraWebRTCProvider):
self._session = session
self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {}
self._hls_view: Go2RtcHlsView | None = None
async def async_setup_hls(self) -> None:
"""Set up HLS support."""
self._hls_view = Go2RtcHlsView(self._hass, self._url)
self._hass.http.register_view(self._hls_view)
@property
def domain(self) -> str:
@@ -236,6 +259,14 @@ class WebRTCProvider(CameraWebRTCProvider):
"""Return if this provider is supports the Camera as source."""
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
async def async_get_stream_url(self, camera: Camera) -> str:
"""Get HLS stream URL for the camera."""
# Ensure stream is configured in go2rtc
await self._update_stream_source(camera)
# Return the HLS playlist URL through our proxy
return f"/api/go2rtc/hls/{camera.entity_id}/playlist.m3u8"
async def async_handle_async_webrtc_offer(
self,
camera: Camera,

View File

@@ -7,3 +7,6 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.9"
# HLS provider constants
GO2RTC_HLS_PROVIDER = "go2rtc_hls"

View File

@@ -0,0 +1,63 @@
"""Web views for go2rtc integration."""
from __future__ import annotations
import logging
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
class Go2RtcHlsView(HomeAssistantView):
"""View to proxy HLS requests to go2rtc server."""
url = r"/api/go2rtc/hls/{entity_id}/{file_name:.*}"
name = "api:go2rtc:hls"
requires_auth = False
def __init__(self, hass: HomeAssistant, go2rtc_url: str) -> None:
"""Initialize the view."""
self.hass = hass
self.go2rtc_url = go2rtc_url.rstrip('/')
async def get(self, request: web.Request, entity_id: str, file_name: str) -> web.Response:
"""Proxy HLS requests to go2rtc server."""
# Validate entity_id exists and is accessible
if entity_id not in self.hass.states.async_entity_ids("camera"):
raise web.HTTPNotFound()
# Proxy request to go2rtc server
# go2rtc uses stream.m3u8?src=entity_id format
if file_name == "playlist.m3u8":
url = f"{self.go2rtc_url}/api/stream.m3u8"
params = {"src": entity_id}
else:
# For segment files, proxy directly
url = f"{self.go2rtc_url}/api/{file_name}"
params = {"src": entity_id}
params.update(request.query)
session = async_get_clientsession(self.hass)
try:
async with session.get(url, params=params) as resp:
if resp.status != 200:
raise web.HTTPNotFound()
content_type = resp.headers.get('Content-Type', 'application/vnd.apple.mpegurl')
body = await resp.read()
return web.Response(
body=body,
content_type=content_type,
headers={'Access-Control-Allow-Origin': '*'}
)
except Exception as err:
_LOGGER.error("Error proxying HLS request to go2rtc: %s", err)
raise web.HTTPInternalServerError() from err

View File

@@ -0,0 +1,165 @@
"""Test go2rtc HLS integration with camera component."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.camera import _async_stream_endpoint_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.components.camera.common import mock_turbo_jpeg
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_turbo() -> None:
"""Mock TurboJPEG."""
mock_turbo_jpeg()
async def test_stream_endpoint_prefers_go2rtc_hls(hass: HomeAssistant) -> None:
"""Test that camera streaming prefers go2rtc HLS when available."""
# Mock camera
camera = Mock()
camera.entity_id = "camera.test"
camera.stream_source = AsyncMock(return_value="rtsp://example.com/stream")
camera.async_create_stream = AsyncMock(return_value=None)
# Mock go2rtc HLS provider
hls_provider = Mock()
hls_provider.async_is_supported = Mock(return_value=True)
hls_provider.async_get_stream_url = AsyncMock(return_value="/api/go2rtc_hls/camera.test/playlist.m3u8")
# Set up hass.data with go2rtc HLS provider
hass.data["go2rtc"] = {"hls_provider": hls_provider}
# Test that go2rtc HLS is used
url = await _async_stream_endpoint_url(hass, camera, "hls")
assert url == "/api/go2rtc_hls/camera.test/playlist.m3u8"
# Verify go2rtc methods were called
hls_provider.async_is_supported.assert_called_once_with("rtsp://example.com/stream")
hls_provider.async_get_stream_url.assert_called_once_with(camera)
# Verify stream integration was not used
camera.async_create_stream.assert_not_called()
async def test_stream_endpoint_fallback_to_stream_integration(hass: HomeAssistant) -> None:
"""Test that camera streaming falls back to stream integration when go2rtc is not available."""
# Mock camera
camera = Mock()
camera.entity_id = "camera.test"
camera.stream_source = AsyncMock(return_value="rtsp://example.com/stream")
# Mock stream integration
stream = Mock()
stream.add_provider = Mock()
stream.start = AsyncMock()
stream.endpoint_url = Mock(return_value="/api/hls/token/master_playlist.m3u8")
camera.async_create_stream = AsyncMock(return_value=stream)
# No go2rtc provider available
hass.data.clear()
# Test that stream integration is used
url = await _async_stream_endpoint_url(hass, camera, "hls")
assert url == "/api/hls/token/master_playlist.m3u8"
# Verify stream integration methods were called
camera.async_create_stream.assert_called_once()
stream.add_provider.assert_called_once_with("hls")
stream.start.assert_called_once()
stream.endpoint_url.assert_called_once_with("hls")
async def test_stream_endpoint_fallback_when_go2rtc_unsupported(hass: HomeAssistant) -> None:
"""Test fallback to stream integration when go2rtc doesn't support the camera."""
# Mock camera with unsupported stream source
camera = Mock()
camera.entity_id = "camera.test"
camera.stream_source = AsyncMock(return_value="unsupported://stream")
# Mock stream integration
stream = Mock()
stream.add_provider = Mock()
stream.start = AsyncMock()
stream.endpoint_url = Mock(return_value="/api/hls/token/master_playlist.m3u8")
camera.async_create_stream = AsyncMock(return_value=stream)
# Mock go2rtc HLS provider that doesn't support this stream
hls_provider = Mock()
hls_provider.async_is_supported = Mock(return_value=False)
hass.data["go2rtc"] = {"hls_provider": hls_provider}
# Test that stream integration is used
url = await _async_stream_endpoint_url(hass, camera, "hls")
assert url == "/api/hls/token/master_playlist.m3u8"
# Verify go2rtc was checked but rejected
hls_provider.async_is_supported.assert_called_once_with("unsupported://stream")
# Verify stream integration was used as fallback
camera.async_create_stream.assert_called_once()
async def test_stream_endpoint_fallback_when_go2rtc_fails(hass: HomeAssistant) -> None:
"""Test fallback to stream integration when go2rtc fails."""
# Mock camera
camera = Mock()
camera.entity_id = "camera.test"
camera.stream_source = AsyncMock(return_value="rtsp://example.com/stream")
# Mock stream integration
stream = Mock()
stream.add_provider = Mock()
stream.start = AsyncMock()
stream.endpoint_url = Mock(return_value="/api/hls/token/master_playlist.m3u8")
camera.async_create_stream = AsyncMock(return_value=stream)
# Mock go2rtc HLS provider that fails
hls_provider = Mock()
hls_provider.async_is_supported = Mock(return_value=True)
hls_provider.async_get_stream_url = AsyncMock(side_effect=Exception("go2rtc failed"))
hass.data["go2rtc"] = {"hls_provider": hls_provider}
# Test that stream integration is used as fallback
url = await _async_stream_endpoint_url(hass, camera, "hls")
assert url == "/api/hls/token/master_playlist.m3u8"
# Verify go2rtc was attempted but failed
hls_provider.async_get_stream_url.assert_called_once_with(camera)
# Verify stream integration was used as fallback
camera.async_create_stream.assert_called_once()
async def test_stream_endpoint_non_hls_format_uses_stream_integration(hass: HomeAssistant) -> None:
"""Test that non-HLS formats always use stream integration."""
# Mock camera
camera = Mock()
camera.entity_id = "camera.test"
camera.stream_source = AsyncMock(return_value="rtsp://example.com/stream")
# Mock stream integration
stream = Mock()
stream.add_provider = Mock()
stream.start = AsyncMock()
stream.endpoint_url = Mock(return_value="/api/recorder/token/recording.mp4")
camera.async_create_stream = AsyncMock(return_value=stream)
# Mock go2rtc HLS provider (should not be used for non-HLS)
hls_provider = Mock()
hass.data["go2rtc"] = {"hls_provider": hls_provider}
# Test with recorder format
url = await _async_stream_endpoint_url(hass, camera, "recorder")
assert url == "/api/recorder/token/recording.mp4"
# Verify go2rtc was not used
assert not hls_provider.method_calls
# Verify stream integration was used
camera.async_create_stream.assert_called_once()
stream.add_provider.assert_called_once_with("recorder")

View File

@@ -696,3 +696,51 @@ async def test_generic_workaround(
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
@pytest.mark.usefixtures("init_integration")
async def test_hls_provider_setup(
hass: HomeAssistant,
init_test_integration: MockCamera,
) -> None:
"""Test HLS provider is properly set up."""
from homeassistant.components.go2rtc.const import DOMAIN as GO2RTC_DOMAIN
# Check that HLS provider is available in hass.data
assert GO2RTC_DOMAIN in hass.data
assert "hls_provider" in hass.data[GO2RTC_DOMAIN]
hls_provider = hass.data[GO2RTC_DOMAIN]["hls_provider"]
assert hls_provider is not None
# Test that the provider supports common stream sources
assert hls_provider.async_is_supported("rtsp://example.com/stream")
assert hls_provider.async_is_supported("http://example.com/stream.m3u8")
assert not hls_provider.async_is_supported("invalid://stream")
@pytest.mark.usefixtures("init_integration")
async def test_hls_stream_url_generation(
hass: HomeAssistant,
init_test_integration: MockCamera,
rest_client: AsyncMock,
) -> None:
"""Test HLS stream URL generation."""
from homeassistant.components.go2rtc.const import DOMAIN as GO2RTC_DOMAIN
camera = init_test_integration
hls_provider = hass.data[GO2RTC_DOMAIN]["hls_provider"]
# Test URL generation
url = await hls_provider.async_get_stream_url(camera)
expected_url = f"/api/go2rtc/hls/{camera.entity_id}/playlist.m3u8"
assert url == expected_url
# Verify stream was configured in go2rtc
rest_client.streams.add.assert_called_with(
camera.entity_id,
[
"rtsp://stream",
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)