From c9356868f730a5cbb97aba7ef6c729a52168cace Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:29:57 +0200 Subject: [PATCH] Add add-on discovery flow to pyLoad integration (#148494) --- .../components/pyload/config_flow.py | 58 ++++++ homeassistant/components/pyload/strings.json | 12 ++ tests/components/pyload/conftest.py | 16 ++ tests/components/pyload/test_config_flow.py | 191 +++++++++++++++++- 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 50d354d345d..1a1481f9c26 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -97,6 +98,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,3 +214,58 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for pyLoad add-on. + + This flow is triggered by the discovery component. + """ + url = URL(discovery_info.config[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + discovery_info.config[CONF_URL] = url + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + + data = {**self._hassio_discovery.config, CONF_VERIFY_SSL: False} + + if user_input is not None: + data.update(user_input) + + try: + await validate_input(self.hass, data) + except (CannotConnect, ParserError): + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders=self._hassio_discovery.config, + ) + return self.async_create_entry(title=self._hassio_discovery.slug, data=data) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=REAUTH_SCHEMA, suggested_values=data + ), + description_placeholders=self._hassio_discovery.config, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 9414f7f7bb8..66435fd2806 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -39,6 +39,18 @@ "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]" } + }, + "hassio_confirm": { + "title": "pyLoad via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the pyLoad service provided by the add-on: {addon}?", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" + } } }, "error": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 9b410a5fdd6..72fabfa3de1 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry @@ -39,6 +40,21 @@ NEW_INPUT = { } +ADDON_DISCOVERY_INFO = { + "addon": "pyLoad-ng", + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", +} + +ADDON_SERVICE_INFO = HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="pyLoad-ng Addon", + slug="p539df76c_pyload-ng", + uuid="1234", +) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 492e4a4b652..1eafbd2eb66 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,18 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT +from .conftest import ( + ADDON_DISCOVERY_INFO, + ADDON_SERVICE_INFO, + NEW_INPUT, + REAUTH_INPUT, + USER_INPUT, +) from tests.common import MockConfigEntry @@ -245,3 +252,183 @@ async def test_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == USER_INPUT assert len(hass.config_entries.async_entries()) == 1 + + +async def test_hassio_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = InvalidAuth + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery. Abort with confirm only.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_hassio_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_data_update( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update entry from discovery data.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://539df76c-pyload-ng:8000/" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"