Add config entry to go2rtc (#129436)

* Add config entry to go2rtc

* Address review comments

* Remove config entry if go2rtc is not configured

* Allow importing default_config

* Address review comment
This commit is contained in:
Erik Montnemery 2024-10-30 13:39:54 +01:00 committed by GitHub
parent 24829bc44f
commit db81edfb2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 244 additions and 15 deletions

View File

@ -3,7 +3,9 @@
import logging
import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError
from go2rtc_client.ws import (
Go2RtcWsClient,
ReceiveMessages,
@ -24,11 +26,15 @@ from homeassistant.components.camera import (
WebRTCSendMessage,
async_register_webrtc_provider,
)
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.package import is_docker_env
from .const import DOMAIN
@ -72,15 +78,24 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC."""
url: str | None = None
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass)
return True
if not (configured_by_user := DOMAIN in config) or not (
url := config[DOMAIN].get(CONF_URL)
):
if not is_docker_env():
if not configured_by_user:
# Remove config entry if it exists
await _remove_go2rtc_entries(hass)
return True
_LOGGER.warning("Go2rtc URL required in non-docker installs")
return False
@ -99,12 +114,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
url = "http://localhost:1984/"
hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
"""Remove go2rtc config entries, if any."""
for entry in hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.async_remove(entry.entry_id)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC]
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.streams.list()
except Exception: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s", url)
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {url}"
) from err
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
provider = WebRTCProvider(hass, url)
@ -112,6 +151,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a go2rtc config entry."""
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")

View File

@ -0,0 +1,21 @@
"""Config flow for the go2rtc integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class CloudConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for the go2rtc integration."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the system step."""
return self.async_create_entry(title="go2rtc", data={})

View File

@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b3"]
"requirements": ["go2rtc-client==0.0.1b3"],
"single_config_entry": true
}

View File

@ -121,6 +121,7 @@ ALLOWED_USED_COMPONENTS = {
"alert",
"automation",
"conversation",
"default_config",
"device_automation",
"frontend",
"group",

View File

@ -0,0 +1,45 @@
"""Test the Home Assistant Cloud config flow."""
from unittest.mock import patch
from homeassistant.components.go2rtc.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_config_flow(hass: HomeAssistant) -> None:
"""Test create cloud entry."""
with (
patch(
"homeassistant.components.go2rtc.async_setup", return_value=True
) as mock_setup,
patch(
"homeassistant.components.go2rtc.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "go2rtc"
assert result["data"] == {}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_multiple_entries(hass: HomeAssistant) -> None:
"""Test creating multiple cloud entries."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"

View File

@ -5,7 +5,9 @@ import logging
from typing import NamedTuple
from unittest.mock import AsyncMock, Mock, patch
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Stream
from go2rtc_client.exceptions import Go2RtcClientError
from go2rtc_client.models import Producer
from go2rtc_client.ws import (
ReceiveMessages,
@ -27,9 +29,10 @@ from homeassistant.components.camera import (
WebRTCMessage,
WebRTCSendMessage,
)
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
from homeassistant.components.go2rtc import WebRTCProvider
from homeassistant.components.go2rtc.const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
@ -100,6 +103,21 @@ def mock_get_binary(go2rtc_binary) -> Generator[Mock]:
yield mock_which
@pytest.fixture(name="has_go2rtc_entry")
def has_go2rtc_entry_fixture() -> bool:
"""Fixture to control if a go2rtc config entry should be created."""
return True
@pytest.fixture
def mock_go2rtc_entry(hass: HomeAssistant, has_go2rtc_entry: bool) -> None:
"""Mock a go2rtc onfig entry."""
if not has_go2rtc_entry:
return
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
@pytest.fixture(name="is_docker_env")
def is_docker_env_fixture() -> bool:
"""Fixture to provide is_docker_env return value."""
@ -187,7 +205,10 @@ async def _test_setup_and_signaling(
assert camera.frontend_stream_type == StreamType.HLS
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == 1
assert config_entries[0].state == ConfigEntryState.LOADED
after_setup_fn()
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
@ -239,8 +260,13 @@ async def _test_setup_and_signaling(
@pytest.mark.usefixtures(
"init_test_integration", "mock_get_binary", "mock_is_docker_env"
"init_test_integration",
"mock_get_binary",
"mock_is_docker_env",
"mock_go2rtc_entry",
)
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
async def test_setup_go_binary(
hass: HomeAssistant,
rest_client: AsyncMock,
@ -249,21 +275,25 @@ async def test_setup_go_binary(
server_start: Mock,
server_stop: Mock,
init_test_integration: MockCamera,
has_go2rtc_entry: bool,
config: ConfigType,
) -> None:
"""Test the go2rtc config entry with binary."""
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
def after_setup() -> None:
server.assert_called_once_with(hass, "/usr/bin/go2rtc")
server_start.assert_called_once()
await _test_setup_and_signaling(
hass, rest_client, ws_client, {DOMAIN: {}}, after_setup, init_test_integration
hass, rest_client, ws_client, config, after_setup, init_test_integration
)
await hass.async_stop()
server_stop.assert_called_once()
@pytest.mark.usefixtures("mock_go2rtc_entry")
@pytest.mark.parametrize(
("go2rtc_binary", "is_docker_env"),
[
@ -271,6 +301,7 @@ async def test_setup_go_binary(
(None, False),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
async def test_setup_go(
hass: HomeAssistant,
rest_client: AsyncMock,
@ -279,8 +310,11 @@ async def test_setup_go(
init_test_integration: MockCamera,
mock_get_binary: Mock,
mock_is_docker_env: Mock,
has_go2rtc_entry: bool,
) -> None:
"""Test the go2rtc config entry without binary."""
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
def after_setup() -> None:
@ -431,6 +465,9 @@ async def test_close_session(
ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary"
ERR_CONNECT = "Could not connect to go2rtc instance"
ERR_CONNECT_RETRY = (
"Could not connect to go2rtc instance on http://localhost:1984/; Retrying"
)
ERR_INVALID_URL = "Invalid config for 'go2rtc': invalid url"
ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs"
@ -441,7 +478,10 @@ ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs"
({}, None, False),
],
)
@pytest.mark.usefixtures("mock_get_binary", "mock_is_docker_env", "server")
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_non_user_setup_with_error(
hass: HomeAssistant,
config: ConfigType,
@ -450,28 +490,105 @@ async def test_non_user_setup_with_error(
"""Test setup integration does not fail if not setup by user."""
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
assert not hass.config_entries.async_entries(DOMAIN)
@pytest.mark.parametrize(
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
[
({}, None, True, ERR_BINARY_NOT_FOUND),
({}, "/usr/bin/go2rtc", True, ERR_CONNECT),
({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND),
({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", "server")
async def test_setup_with_error(
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_setup_error(
hass: HomeAssistant,
config: ConfigType,
caplog: pytest.LogCaptureFixture,
has_go2rtc_entry: bool,
expected_log_message: str,
) -> None:
"""Test setup integration fails."""
assert not await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
assert bool(hass.config_entries.async_entries(DOMAIN)) == has_go2rtc_entry
assert expected_log_message in caplog.text
@pytest.mark.parametrize(
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
[
({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_setup_entry_error(
hass: HomeAssistant,
config: ConfigType,
caplog: pytest.LogCaptureFixture,
expected_log_message: str,
) -> None:
"""Test setup integration entry fails."""
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == 1
assert config_entries[0].state == ConfigEntryState.SETUP_ERROR
assert expected_log_message in caplog.text
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
@pytest.mark.parametrize(
("cause", "expected_config_entry_state", "expected_log_message"),
[
(ClientConnectionError(), ConfigEntryState.SETUP_RETRY, ERR_CONNECT_RETRY),
(ServerConnectionError(), ConfigEntryState.SETUP_RETRY, ERR_CONNECT_RETRY),
(None, ConfigEntryState.SETUP_ERROR, ERR_CONNECT),
(Exception(), ConfigEntryState.SETUP_ERROR, ERR_CONNECT),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_retryable_setup_entry_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
rest_client: AsyncMock,
config: ConfigType,
cause: Exception,
expected_config_entry_state: ConfigEntryState,
expected_log_message: str,
) -> None:
"""Test setup integration entry fails."""
go2rtc_error = Go2RtcClientError()
go2rtc_error.__cause__ = cause
rest_client.streams.list.side_effect = go2rtc_error
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == 1
assert config_entries[0].state == expected_config_entry_state
assert expected_log_message in caplog.text
async def test_config_entry_remove(hass: HomeAssistant) -> None:
"""Test config entry removed when neither default_config nor go2rtc is in config."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert not await hass.config_entries.async_setup(config_entry.entry_id)
assert len(hass.config_entries.async_entries(DOMAIN)) == 0