diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 27ec140076b05e99ba0b8287635beca6fe0cc441..1a0b6fee6db9a82f5363f4fe9cdea4286fd3cc91 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,20 +1,27 @@ """The go2rtc component.""" +import logging +import shutil + from go2rtc_client import Go2RtcClient, WebRTCSdpOffer +import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.components.camera.webrtc import ( CameraWebRTCProvider, async_register_webrtc_provider, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.package import is_docker_env -from .const import CONF_BINARY +from .const import DOMAIN from .server import Server +_LOGGER = logging.getLogger(__name__) _SUPPORTED_STREAMS = frozenset( ( "bubble", @@ -46,22 +53,49 @@ _SUPPORTED_STREAMS = frozenset( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up WebRTC from a config entry.""" - if binary := entry.data.get(CONF_BINARY): +CONFIG_SCHEMA = vol.Schema({DOMAIN: {vol.Optional(CONF_URL): cv.url}}) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up WebRTC.""" + url: str | None = None + if not (url := config[DOMAIN].get(CONF_URL)): + if not is_docker_env(): + _LOGGER.warning("Go2rtc URL required in non-docker installs") + return False + if not (binary := await _get_binary(hass)): + _LOGGER.error("Could not find go2rtc docker binary") + return False + # HA will manage the binary server = Server(hass, binary) - - entry.async_on_unload(server.stop) await server.start() - client = Go2RtcClient(async_get_clientsession(hass), entry.data[CONF_URL]) + async def on_stop(event: Event) -> None: + await server.stop() + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) + + url = "http://localhost:1984/" + + # Validate the server URL + try: + client = Go2RtcClient(async_get_clientsession(hass), url) + await client.streams.list() + except Exception: # noqa: BLE001 + _LOGGER.warning("Could not connect to go2rtc instance on %s", url) + return False provider = WebRTCProvider(client) - entry.async_on_unload(async_register_webrtc_provider(hass, provider)) + async_register_webrtc_provider(hass, provider) return True +async def _get_binary(hass: HomeAssistant) -> str | None: + """Return the binary path if found.""" + return await hass.async_add_executor_job(shutil.which, "go2rtc") + + class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" @@ -87,8 +121,3 @@ class WebRTCProvider(CameraWebRTCProvider): camera.entity_id, WebRTCSdpOffer(offer_sdp) ) return answer.sdp - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return True diff --git a/homeassistant/components/go2rtc/config_flow.py b/homeassistant/components/go2rtc/config_flow.py deleted file mode 100644 index 0b1f37803461dbc0489d64ce76ac53663d842a51..0000000000000000000000000000000000000000 --- a/homeassistant/components/go2rtc/config_flow.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Config flow for WebRTC.""" - -from __future__ import annotations - -import shutil -from typing import Any -from urllib.parse import urlparse - -from go2rtc_client import Go2RtcClient -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.package import is_docker_env - -from .const import CONF_BINARY, DOMAIN - -_VALID_URL_SCHEMA = {"http", "https"} - - -async def _validate_url( - hass: HomeAssistant, - value: str, -) -> str | None: - """Validate the URL and return error or None if it's valid.""" - if urlparse(value).scheme not in _VALID_URL_SCHEMA: - return "invalid_url_schema" - try: - vol.Schema(vol.Url())(value) - except vol.Invalid: - return "invalid_url" - - try: - client = Go2RtcClient(async_get_clientsession(hass), value) - await client.streams.list() - except Exception: # noqa: BLE001 - return "cannot_connect" - return None - - -class Go2RTCConfigFlow(ConfigFlow, domain=DOMAIN): - """go2rtc config flow.""" - - def _get_binary(self) -> str | None: - """Return the binary path if found.""" - return shutil.which(DOMAIN) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Init step.""" - if is_docker_env() and (binary := self._get_binary()): - return self.async_create_entry( - title=DOMAIN, - data={CONF_BINARY: binary, CONF_URL: "http://localhost:1984/"}, - ) - - return await self.async_step_url() - - async def async_step_url( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step to use selfhosted go2rtc server.""" - errors = {} - if user_input is not None: - if error := await _validate_url(self.hass, user_input[CONF_URL]): - errors[CONF_URL] = error - else: - return self.async_create_entry(title=DOMAIN, data=user_input) - - return self.async_show_form( - step_id="url", - data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_URL): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.URL - ) - ), - } - ), - suggested_values=user_input, - ), - errors=errors, - last_step=True, - ) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index faf6c991ac1719804c29d4d2cc6551c0d611046a..ff32b85f72f142301ff2b401915a65f103077b58 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -2,10 +2,10 @@ "domain": "go2rtc", "name": "go2rtc", "codeowners": ["@home-assistant/core"], - "config_flow": true, + "config_flow": false, "dependencies": ["camera"], "documentation": "https://www.home-assistant.io/integrations/go2rtc", + "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b0"], - "single_config_entry": true + "requirements": ["go2rtc-client==0.0.1b0"] } diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json deleted file mode 100644 index 0258dcac69e388f07108e0d78c1ece4940d8bafa..0000000000000000000000000000000000000000 --- a/homeassistant/components/go2rtc/strings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "step": { - "url": { - "data": { - "url": "[%key:common::config_flow::data::url%]" - }, - "data_description": { - "url": "The URL of your go2rtc instance." - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_url": "Invalid URL", - "invalid_url_schema": "Invalid URL scheme.\nThe URL should start with `http://` or `https://`." - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 557f1b4796f111f23799e52b6b389a8238f1606f..c90159ff71614baca9f50cc4dadbc95136adedec 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -221,7 +221,6 @@ FLOWS = { "gios", "github", "glances", - "go2rtc", "goalzero", "gogogate2", "goodwe", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 11f5f211b43d0c7684b4910bc303e85990b25191..0b0d2ad47efce2ca156a6599507e001b528f936b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2246,13 +2246,6 @@ } } }, - "go2rtc": { - "name": "go2rtc", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true - }, "goalzero": { "name": "Goal Zero Yeti", "integration_type": "device", diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index 20cbd67d571e4bae34e67a5423f5836cdb13cc21..0971541efa5296e916dc677febfec5e8a85aeea4 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -1,13 +1 @@ """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() diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index b1c0f64121d60f428f67a95a61c905afb0feef1f..d0e9bbb8826b47fc6378cb797483a54fa189b6d7 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -6,21 +6,9 @@ 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.components.go2rtc.server import Server -from homeassistant.const import CONF_URL -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 +GO2RTC_PATH = "homeassistant.components.go2rtc" @pytest.fixture @@ -30,10 +18,6 @@ def mock_client() -> Generator[AsyncMock]: 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) @@ -42,19 +26,33 @@ def mock_client() -> Generator[AsyncMock]: @pytest.fixture -def mock_server() -> Generator[AsyncMock]: - """Mock a go2rtc server.""" - with patch( - "homeassistant.components.go2rtc.Server", spec_set=Server - ) as mock_server: - yield mock_server +def mock_server_start() -> Generator[AsyncMock]: + """Mock start of a go2rtc server.""" + with ( + patch(f"{GO2RTC_PATH}.server.asyncio.create_subprocess_exec") as mock_subproc, + patch( + f"{GO2RTC_PATH}.server.Server.start", wraps=Server.start, autospec=True + ) as mock_server_start, + ): + subproc = AsyncMock() + subproc.terminate = Mock() + mock_subproc.return_value = subproc + yield mock_server_start @pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Mock a config entry.""" - return MockConfigEntry( - domain=DOMAIN, - title=DOMAIN, - data={CONF_URL: "http://localhost:1984/", CONF_BINARY: "/usr/bin/go2rtc"}, - ) +def mock_server_stop() -> Generator[AsyncMock]: + """Mock stop of a go2rtc server.""" + with ( + patch( + f"{GO2RTC_PATH}.server.Server.stop", wraps=Server.stop, autospec=True + ) as mock_server_stop, + ): + yield mock_server_stop + + +@pytest.fixture +def mock_server(mock_server_start, mock_server_stop) -> Generator[AsyncMock]: + """Mock a go2rtc server.""" + with patch(f"{GO2RTC_PATH}.Server", wraps=Server) as mock_server: + yield mock_server diff --git a/tests/components/go2rtc/test_config_flow.py b/tests/components/go2rtc/test_config_flow.py deleted file mode 100644 index 4af599810d7cf0dda330d856f2d7109e83ffe6c2..0000000000000000000000000000000000000000 --- a/tests/components/go2rtc/test_config_flow.py +++ /dev/null @@ -1,156 +0,0 @@ -"""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_URL -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_URL: "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_url( - hass: HomeAssistant, - is_docker_env: bool, - shutil_which: str | None, -) -> None: - """Test config flow with url 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"] == "url" - url = "http://go2rtc.local:1984/" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: url}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "go2rtc" - assert result["data"] == { - CONF_URL: url, - } - - -@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"] == "url" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "go2rtc.local:1984/"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"url": "invalid_url_schema"} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "http://"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"url": "invalid_url"} - - url = "http://go2rtc.local:1984/" - mock_client.streams.list.side_effect = Exception - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: url}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"url": "cannot_connect"} - - mock_client.streams.list.side_effect = None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: url}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "go2rtc" - assert result["data"] == { - CONF_URL: url, - } diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index f95e98825aef93de91d8f0379735a7a198bc6117..690bd83b37c7ba694a47dfd49eb4261e0b7d0237 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,7 +1,7 @@ """The tests for the go2rtc component.""" -from collections.abc import Callable -from unittest.mock import AsyncMock, Mock +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, Mock, patch from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer from go2rtc_client.models import Producer @@ -16,12 +16,12 @@ 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.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError - -from . import setup_integration +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -78,6 +78,38 @@ def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: return entry +@pytest.fixture(name="go2rtc_binary") +def go2rtc_binary_fixture() -> str: + """Fixture to provide go2rtc binary name.""" + return "/usr/bin/go2rtc" + + +@pytest.fixture +def mock_get_binary(go2rtc_binary) -> Generator[Mock]: + """Mock _get_binary.""" + with patch( + "homeassistant.components.go2rtc.shutil.which", + return_value=go2rtc_binary, + ) as mock_which: + yield mock_which + + +@pytest.fixture(name="is_docker_env") +def is_docker_env_fixture() -> bool: + """Fixture to provide is_docker_env return value.""" + return True + + +@pytest.fixture +def mock_is_docker_env(is_docker_env) -> Generator[Mock]: + """Mock is_docker_env.""" + with patch( + "homeassistant.components.go2rtc.is_docker_env", + return_value=is_docker_env, + ) as mock_is_docker_env: + yield mock_is_docker_env + + @pytest.fixture async def init_test_integration( hass: HomeAssistant, @@ -124,11 +156,10 @@ async def init_test_integration( return integration_config_entry -@pytest.mark.usefixtures("init_test_integration") async def _test_setup( hass: HomeAssistant, mock_client: AsyncMock, - mock_config_entry: MockConfigEntry, + config: ConfigType, after_setup_fn: Callable[[], None], ) -> None: """Test the go2rtc config entry.""" @@ -136,7 +167,8 @@ async def _test_setup( camera = get_camera_from_entity_id(hass, entity_id) assert camera.frontend_stream_type == StreamType.HLS - await setup_integration(hass, mock_config_entry) + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() after_setup_fn() mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP) @@ -170,50 +202,83 @@ async def _test_setup( ): 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") +@pytest.mark.usefixtures( + "init_test_integration", "mock_get_binary", "mock_is_docker_env" +) async def test_setup_go_binary( hass: HomeAssistant, mock_client: AsyncMock, mock_server: AsyncMock, - mock_config_entry: MockConfigEntry, + mock_server_start: Mock, + mock_server_stop: Mock, ) -> None: """Test the go2rtc config entry with binary.""" def after_setup() -> None: mock_server.assert_called_once_with(hass, "/usr/bin/go2rtc") - mock_server.return_value.start.assert_called_once() + mock_server_start.assert_called_once() - await _test_setup(hass, mock_client, mock_config_entry, after_setup) + await _test_setup(hass, mock_client, {DOMAIN: {}}, after_setup) - mock_server.return_value.stop.assert_called_once() + await hass.async_stop() + mock_server_stop.assert_called_once() +@pytest.mark.parametrize( + ("go2rtc_binary", "is_docker_env"), + [ + ("/usr/bin/go2rtc", True), + (None, False), + ], +) @pytest.mark.usefixtures("init_test_integration") async def test_setup_go( hass: HomeAssistant, mock_client: AsyncMock, mock_server: Mock, + mock_get_binary: Mock, + mock_is_docker_env: Mock, ) -> None: """Test the go2rtc config entry without binary.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - title=DOMAIN, - data={CONF_URL: "http://localhost:1984/"}, - ) + config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} def after_setup() -> None: mock_server.assert_not_called() - await _test_setup(hass, mock_client, config_entry, after_setup) + await _test_setup(hass, mock_client, config, after_setup) + mock_get_binary.assert_not_called() + mock_get_binary.assert_not_called() mock_server.assert_not_called() + + +ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary" +ERR_CONNECT = "Could not connect to go2rtc instance" +ERR_INVALID_URL = "Invalid config for 'go2rtc': invalid url" +ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs" + + +@pytest.mark.parametrize( + ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), + [ + ({}, None, False, "KeyError: 'go2rtc'"), + ({}, None, True, "KeyError: 'go2rtc'"), + ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), + ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), + ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), + ({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT), + ], +) +@pytest.mark.usefixtures("mock_get_binary", "mock_is_docker_env", "mock_server") +async def test_setup_with_error( + hass: HomeAssistant, + config: ConfigType, + caplog: pytest.LogCaptureFixture, + expected_log_message: str, +) -> None: + """Test setup integration fails.""" + + assert not await async_setup_component(hass, DOMAIN, config) + assert expected_log_message in caplog.text