Remove go2rtc config flow (#129020)

* Remove go2rtc config flow

* Address review comments

* Update manifest

* Always validate go2rtc server URL

* Remove extra client

* Update homeassistant/components/go2rtc/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Improve test coverage

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Erik Montnemery 2024-10-25 11:13:43 +02:00 committed by GitHub
parent bc0e3b254b
commit bed77bd356
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 169 additions and 362 deletions

View File

@ -1,20 +1,27 @@
"""The go2rtc component.""" """The go2rtc component."""
import logging
import shutil
from go2rtc_client import Go2RtcClient, WebRTCSdpOffer from go2rtc_client import Go2RtcClient, WebRTCSdpOffer
import voluptuous as vol
from homeassistant.components.camera import Camera from homeassistant.components.camera import Camera
from homeassistant.components.camera.webrtc import ( from homeassistant.components.camera.webrtc import (
CameraWebRTCProvider, CameraWebRTCProvider,
async_register_webrtc_provider, async_register_webrtc_provider,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import CONF_URL from homeassistant.core import Event, HomeAssistant
from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 from .server import Server
_LOGGER = logging.getLogger(__name__)
_SUPPORTED_STREAMS = frozenset( _SUPPORTED_STREAMS = frozenset(
( (
"bubble", "bubble",
@ -46,22 +53,49 @@ _SUPPORTED_STREAMS = frozenset(
) )
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONFIG_SCHEMA = vol.Schema({DOMAIN: {vol.Optional(CONF_URL): cv.url}})
"""Set up WebRTC from a config entry."""
if binary := entry.data.get(CONF_BINARY):
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 # HA will manage the binary
server = Server(hass, binary) server = Server(hass, binary)
entry.async_on_unload(server.stop)
await server.start() 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) provider = WebRTCProvider(client)
entry.async_on_unload(async_register_webrtc_provider(hass, provider)) async_register_webrtc_provider(hass, provider)
return True 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): class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider.""" """WebRTC provider."""
@ -87,8 +121,3 @@ class WebRTCProvider(CameraWebRTCProvider):
camera.entity_id, WebRTCSdpOffer(offer_sdp) camera.entity_id, WebRTCSdpOffer(offer_sdp)
) )
return answer.sdp return answer.sdp
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@ -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,
)

View File

@ -2,10 +2,10 @@
"domain": "go2rtc", "domain": "go2rtc",
"name": "go2rtc", "name": "go2rtc",
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"config_flow": true, "config_flow": false,
"dependencies": ["camera"], "dependencies": ["camera"],
"documentation": "https://www.home-assistant.io/integrations/go2rtc", "documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b0"], "requirements": ["go2rtc-client==0.0.1b0"]
"single_config_entry": true
} }

View File

@ -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://`."
}
}
}

View File

@ -221,7 +221,6 @@ FLOWS = {
"gios", "gios",
"github", "github",
"glances", "glances",
"go2rtc",
"goalzero", "goalzero",
"gogogate2", "gogogate2",
"goodwe", "goodwe",

View File

@ -2246,13 +2246,6 @@
} }
} }
}, },
"go2rtc": {
"name": "go2rtc",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"single_config_entry": true
},
"goalzero": { "goalzero": {
"name": "Goal Zero Yeti", "name": "Goal Zero Yeti",
"integration_type": "device", "integration_type": "device",

View File

@ -1,13 +1 @@
"""Go2rtc tests.""" """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()

View File

@ -6,21 +6,9 @@ from unittest.mock import AsyncMock, Mock, patch
from go2rtc_client.client import _StreamClient, _WebRTCClient from go2rtc_client.client import _StreamClient, _WebRTCClient
import pytest import pytest
from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
from homeassistant.components.go2rtc.server import Server from homeassistant.components.go2rtc.server import Server
from homeassistant.const import CONF_URL
from tests.common import MockConfigEntry GO2RTC_PATH = "homeassistant.components.go2rtc"
@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 @pytest.fixture
@ -30,10 +18,6 @@ def mock_client() -> Generator[AsyncMock]:
patch( patch(
"homeassistant.components.go2rtc.Go2RtcClient", "homeassistant.components.go2rtc.Go2RtcClient",
) as mock_client, ) as mock_client,
patch(
"homeassistant.components.go2rtc.config_flow.Go2RtcClient",
new=mock_client,
),
): ):
client = mock_client.return_value client = mock_client.return_value
client.streams = Mock(spec_set=_StreamClient) client.streams = Mock(spec_set=_StreamClient)
@ -42,19 +26,33 @@ def mock_client() -> Generator[AsyncMock]:
@pytest.fixture @pytest.fixture
def mock_server() -> Generator[AsyncMock]: def mock_server_start() -> Generator[AsyncMock]:
"""Mock a go2rtc server.""" """Mock start of a go2rtc server."""
with patch( with (
"homeassistant.components.go2rtc.Server", spec_set=Server patch(f"{GO2RTC_PATH}.server.asyncio.create_subprocess_exec") as mock_subproc,
) as mock_server: patch(
yield mock_server 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 @pytest.fixture
def mock_config_entry() -> MockConfigEntry: def mock_server_stop() -> Generator[AsyncMock]:
"""Mock a config entry.""" """Mock stop of a go2rtc server."""
return MockConfigEntry( with (
domain=DOMAIN, patch(
title=DOMAIN, f"{GO2RTC_PATH}.server.Server.stop", wraps=Server.stop, autospec=True
data={CONF_URL: "http://localhost:1984/", CONF_BINARY: "/usr/bin/go2rtc"}, ) 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

View File

@ -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,
}

View File

@ -1,7 +1,7 @@
"""The tests for the go2rtc component.""" """The tests for the go2rtc component."""
from collections.abc import Callable from collections.abc import Callable, Generator
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock, patch
from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer
from go2rtc_client.models import Producer 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.camera.helper import get_camera_from_entity_id
from homeassistant.components.go2rtc import WebRTCProvider from homeassistant.components.go2rtc import WebRTCProvider
from homeassistant.components.go2rtc.const import DOMAIN 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.const import CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType
from . import setup_integration from homeassistant.setup import async_setup_component
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
@ -78,6 +78,38 @@ def integration_config_entry(hass: HomeAssistant) -> ConfigEntry:
return entry 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 @pytest.fixture
async def init_test_integration( async def init_test_integration(
hass: HomeAssistant, hass: HomeAssistant,
@ -124,11 +156,10 @@ async def init_test_integration(
return integration_config_entry return integration_config_entry
@pytest.mark.usefixtures("init_test_integration")
async def _test_setup( async def _test_setup(
hass: HomeAssistant, hass: HomeAssistant,
mock_client: AsyncMock, mock_client: AsyncMock,
mock_config_entry: MockConfigEntry, config: ConfigType,
after_setup_fn: Callable[[], None], after_setup_fn: Callable[[], None],
) -> None: ) -> None:
"""Test the go2rtc config entry.""" """Test the go2rtc config entry."""
@ -136,7 +167,8 @@ async def _test_setup(
camera = get_camera_from_entity_id(hass, entity_id) camera = get_camera_from_entity_id(hass, entity_id)
assert camera.frontend_stream_type == StreamType.HLS 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() after_setup_fn()
mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP) 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) 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 == [] @pytest.mark.usefixtures(
assert camera.frontend_stream_type == StreamType.HLS "init_test_integration", "mock_get_binary", "mock_is_docker_env"
)
@pytest.mark.usefixtures("init_test_integration")
async def test_setup_go_binary( async def test_setup_go_binary(
hass: HomeAssistant, hass: HomeAssistant,
mock_client: AsyncMock, mock_client: AsyncMock,
mock_server: AsyncMock, mock_server: AsyncMock,
mock_config_entry: MockConfigEntry, mock_server_start: Mock,
mock_server_stop: Mock,
) -> None: ) -> None:
"""Test the go2rtc config entry with binary.""" """Test the go2rtc config entry with binary."""
def after_setup() -> None: def after_setup() -> None:
mock_server.assert_called_once_with(hass, "/usr/bin/go2rtc") 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") @pytest.mark.usefixtures("init_test_integration")
async def test_setup_go( async def test_setup_go(
hass: HomeAssistant, hass: HomeAssistant,
mock_client: AsyncMock, mock_client: AsyncMock,
mock_server: Mock, mock_server: Mock,
mock_get_binary: Mock,
mock_is_docker_env: Mock,
) -> None: ) -> None:
"""Test the go2rtc config entry without binary.""" """Test the go2rtc config entry without binary."""
config_entry = MockConfigEntry( config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
domain=DOMAIN,
title=DOMAIN,
data={CONF_URL: "http://localhost:1984/"},
)
def after_setup() -> None: def after_setup() -> None:
mock_server.assert_not_called() 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() 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