mirror of
https://github.com/home-assistant/core.git
synced 2025-11-09 10:59:40 +00:00
Add go2rtc and extend camera integration for better WebRTC support (#124410)
This commit is contained in:
13
tests/components/go2rtc/__init__.py
Normal file
13
tests/components/go2rtc/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Go2rtc tests."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the component."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
57
tests/components/go2rtc/conftest.py
Normal file
57
tests/components/go2rtc/conftest.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Go2rtc test configuration."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from go2rtc_client.client import _StreamClient, _WebRTCClient
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.go2rtc.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Generator[AsyncMock]:
|
||||
"""Mock a go2rtc client."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.Go2RtcClient",
|
||||
) as mock_client,
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.Go2RtcClient",
|
||||
new=mock_client,
|
||||
),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.streams = Mock(spec_set=_StreamClient)
|
||||
client.webrtc = Mock(spec_set=_WebRTCClient)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_server() -> Generator[Mock]:
|
||||
"""Mock a go2rtc server."""
|
||||
with patch("homeassistant.components.go2rtc.Server", autoSpec=True) as mock_server:
|
||||
yield mock_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock a config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DOMAIN,
|
||||
data={CONF_HOST: "http://localhost:1984/", CONF_BINARY: "/usr/bin/go2rtc"},
|
||||
)
|
||||
156
tests/components/go2rtc/test_config_flow.py
Normal file
156
tests/components/go2rtc/test_config_flow.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for the Go2rtc config flow."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client", "mock_setup_entry")
|
||||
async def test_single_instance_allowed(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that flow will abort if already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_docker_with_binary(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test config flow, where HA is running in docker with a go2rtc binary available."""
|
||||
binary = "/usr/bin/go2rtc"
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.is_docker_env",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.shutil.which",
|
||||
return_value=binary,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "go2rtc"
|
||||
assert result["data"] == {
|
||||
CONF_BINARY: binary,
|
||||
CONF_HOST: "http://localhost:1984/",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry", "mock_client")
|
||||
@pytest.mark.parametrize(
|
||||
("is_docker_env", "shutil_which"),
|
||||
[
|
||||
(True, None),
|
||||
(False, None),
|
||||
(False, "/usr/bin/go2rtc"),
|
||||
],
|
||||
)
|
||||
async def test_config_flow_host(
|
||||
hass: HomeAssistant,
|
||||
is_docker_env: bool,
|
||||
shutil_which: str | None,
|
||||
) -> None:
|
||||
"""Test config flow with host input."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.is_docker_env",
|
||||
return_value=is_docker_env,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.shutil.which",
|
||||
return_value=shutil_which,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "host"
|
||||
host = "http://go2rtc.local:1984/"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: host},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "go2rtc"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: host,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_flow_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_client: Mock,
|
||||
) -> None:
|
||||
"""Test flow errors."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.config_flow.is_docker_env",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "host"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: "go2rtc.local:1984/"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"host": "invalid_url_schema"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: "http://"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"host": "invalid_url"}
|
||||
|
||||
host = "http://go2rtc.local:1984/"
|
||||
mock_client.streams.list.side_effect = Exception
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: host},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"host": "cannot_connect"}
|
||||
|
||||
mock_client.streams.list.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: host},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "go2rtc"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: host,
|
||||
}
|
||||
219
tests/components/go2rtc/test_init.py
Normal file
219
tests/components/go2rtc/test_init.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""The tests for the go2rtc component."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer
|
||||
from go2rtc_client.models import Producer
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
DOMAIN as CAMERA_DOMAIN,
|
||||
Camera,
|
||||
CameraEntityFeature,
|
||||
)
|
||||
from homeassistant.components.camera.const import StreamType
|
||||
from homeassistant.components.camera.helper import get_camera_from_entity_id
|
||||
from homeassistant.components.go2rtc import WebRTCProvider
|
||||
from homeassistant.components.go2rtc.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
mock_config_flow,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
setup_test_component_platform,
|
||||
)
|
||||
|
||||
TEST_DOMAIN = "test"
|
||||
|
||||
# The go2rtc provider does not inspect the details of the offer and answer,
|
||||
# and is only a pass through.
|
||||
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..."
|
||||
|
||||
|
||||
class MockCamera(Camera):
|
||||
"""Mock Camera Entity."""
|
||||
|
||||
_attr_name = "Test"
|
||||
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the mock entity."""
|
||||
super().__init__()
|
||||
self._stream_source: str | None = "rtsp://stream"
|
||||
|
||||
def set_stream_source(self, stream_source: str | None) -> None:
|
||||
"""Set the stream source."""
|
||||
self._stream_source = stream_source
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the source of the stream.
|
||||
|
||||
This is used by cameras with CameraEntityFeature.STREAM
|
||||
and StreamType.HLS.
|
||||
"""
|
||||
return self._stream_source
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_entity() -> MockCamera:
|
||||
"""Mock Camera Entity."""
|
||||
return MockCamera()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_config_entry(hass: HomeAssistant) -> ConfigEntry:
|
||||
"""Test mock config entry."""
|
||||
entry = MockConfigEntry(domain=TEST_DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_test_integration(
|
||||
hass: HomeAssistant,
|
||||
integration_config_entry: ConfigEntry,
|
||||
integration_entity: MockCamera,
|
||||
) -> None:
|
||||
"""Initialize components."""
|
||||
|
||||
async def async_setup_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up test config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
config_entry, [CAMERA_DOMAIN]
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_unload_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload test config entry."""
|
||||
await hass.config_entries.async_forward_entry_unload(
|
||||
config_entry, CAMERA_DOMAIN
|
||||
)
|
||||
return True
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
TEST_DOMAIN,
|
||||
async_setup_entry=async_setup_entry_init,
|
||||
async_unload_entry=async_unload_entry_init,
|
||||
),
|
||||
)
|
||||
setup_test_component_platform(
|
||||
hass, CAMERA_DOMAIN, [integration_entity], from_config_entry=True
|
||||
)
|
||||
mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
|
||||
|
||||
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
|
||||
assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return integration_config_entry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_test_integration")
|
||||
async def _test_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
after_setup_fn: Callable[[], None],
|
||||
) -> None:
|
||||
"""Test the go2rtc config entry."""
|
||||
entity_id = "camera.test"
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
assert camera.frontend_stream_type == StreamType.HLS
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
after_setup_fn()
|
||||
|
||||
mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP)
|
||||
|
||||
answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
|
||||
assert answer == ANSWER_SDP
|
||||
|
||||
mock_client.webrtc.forward_whep_sdp_offer.assert_called_once_with(
|
||||
entity_id, WebRTCSdpOffer(OFFER_SDP)
|
||||
)
|
||||
mock_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream")
|
||||
|
||||
# If the stream is already added, the stream should not be added again.
|
||||
mock_client.streams.add.reset_mock()
|
||||
mock_client.streams.list.return_value = {
|
||||
entity_id: Stream([Producer("rtsp://stream")])
|
||||
}
|
||||
|
||||
answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
|
||||
assert answer == ANSWER_SDP
|
||||
mock_client.streams.add.assert_not_called()
|
||||
assert mock_client.webrtc.forward_whep_sdp_offer.call_count == 2
|
||||
assert isinstance(camera._webrtc_providers[0], WebRTCProvider)
|
||||
|
||||
# Set stream source to None and provider should be skipped
|
||||
mock_client.streams.list.return_value = {}
|
||||
camera.set_stream_source(None)
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="WebRTC offer was not accepted by the supported providers",
|
||||
):
|
||||
await camera.async_handle_web_rtc_offer(OFFER_SDP)
|
||||
|
||||
# Remove go2rtc config entry
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
await hass.config_entries.async_remove(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
assert camera._webrtc_providers == []
|
||||
assert camera.frontend_stream_type == StreamType.HLS
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_test_integration")
|
||||
async def test_setup_go_binary(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
mock_server: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the go2rtc config entry with binary."""
|
||||
|
||||
def after_setup() -> None:
|
||||
mock_server.assert_called_once_with("/usr/bin/go2rtc")
|
||||
mock_server.return_value.start.assert_called_once()
|
||||
|
||||
await _test_setup(hass, mock_client, mock_config_entry, after_setup)
|
||||
|
||||
mock_server.return_value.stop.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_test_integration")
|
||||
async def test_setup_go(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
mock_server: Mock,
|
||||
) -> None:
|
||||
"""Test the go2rtc config entry without binary."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DOMAIN,
|
||||
data={CONF_HOST: "http://localhost:1984/"},
|
||||
)
|
||||
|
||||
def after_setup() -> None:
|
||||
mock_server.assert_not_called()
|
||||
|
||||
await _test_setup(hass, mock_client, config_entry, after_setup)
|
||||
|
||||
mock_server.assert_not_called()
|
||||
91
tests/components/go2rtc/test_server.py
Normal file
91
tests/components/go2rtc/test_server.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for the go2rtc server."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.go2rtc.server import Server
|
||||
|
||||
TEST_BINARY = "/bin/go2rtc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server() -> Server:
|
||||
"""Fixture to initialize the Server."""
|
||||
return Server(binary=TEST_BINARY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tempfile() -> Generator[MagicMock]:
|
||||
"""Fixture to mock NamedTemporaryFile."""
|
||||
with patch(
|
||||
"homeassistant.components.go2rtc.server.NamedTemporaryFile"
|
||||
) as mock_tempfile:
|
||||
mock_tempfile.return_value.__enter__.return_value.name = "test.yaml"
|
||||
yield mock_tempfile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_popen() -> Generator[MagicMock]:
|
||||
"""Fixture to mock subprocess.Popen."""
|
||||
with patch("homeassistant.components.go2rtc.server.subprocess.Popen") as mock_popen:
|
||||
yield mock_popen
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tempfile")
|
||||
async def test_server_run_success(mock_popen: MagicMock, server: Server) -> None:
|
||||
"""Test that the server runs successfully."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Simulate process running
|
||||
# Simulate process output
|
||||
mock_process.stdout.readline.side_effect = [
|
||||
b"log line 1\n",
|
||||
b"log line 2\n",
|
||||
b"",
|
||||
]
|
||||
mock_popen.return_value.__enter__.return_value = mock_process
|
||||
|
||||
server.start()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Check that Popen was called with the right arguments
|
||||
mock_popen.assert_called_once_with(
|
||||
[TEST_BINARY, "-c", "webrtc.ice_servers=[]", "-c", "test.yaml"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
# Check that server read the log lines
|
||||
assert mock_process.stdout.readline.call_count == 3
|
||||
|
||||
server.stop()
|
||||
mock_process.terminate.assert_called_once()
|
||||
assert not server.is_alive()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tempfile")
|
||||
def test_server_run_process_timeout(mock_popen: MagicMock, server: Server) -> None:
|
||||
"""Test server run where the process takes too long to terminate."""
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Simulate process running
|
||||
# Simulate process output
|
||||
mock_process.stdout.readline.side_effect = [
|
||||
b"log line 1\n",
|
||||
b"",
|
||||
]
|
||||
# Simulate timeout
|
||||
mock_process.wait.side_effect = subprocess.TimeoutExpired(cmd="go2rtc", timeout=5)
|
||||
mock_popen.return_value.__enter__.return_value = mock_process
|
||||
|
||||
# Start server thread
|
||||
server.start()
|
||||
server.stop()
|
||||
|
||||
# Ensure terminate and kill were called due to timeout
|
||||
mock_process.terminate.assert_called_once()
|
||||
mock_process.kill.assert_called_once()
|
||||
assert not server.is_alive()
|
||||
Reference in New Issue
Block a user