diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index ed8999cf7d9d..67c4b9299421 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -33,23 +33,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def create_schema(previous_input=None): - """Create a schema with given values as default.""" - if previous_input is not None: - host = previous_input[CONF_HOST] - port = previous_input[CONF_PORT] - else: - host = DEFAULT_HOST - port = DEFAULT_PORT - - return vol.Schema( - { - vol.Required(CONF_HOST, default=host): str, - vol.Required(CONF_PORT, default=port): int, - vol.Inclusive(CONF_USERNAME, "auth"): str, - vol.Inclusive(CONF_PASSWORD, "auth"): str, - } - ) +STEP_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Inclusive(CONF_USERNAME, "auth"): str, + vol.Inclusive(CONF_PASSWORD, "auth"): str, + } +) LOG_MSG = { @@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN): self.device_config: dict[str, Any] = {} def handle_step_exception( - self, step, exception, schema, host, port, message_id, log_fn + self, exception, schema, host, port, message_id, log_fn, step_id ): """Handle step exceptions.""" log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception) return self.async_show_form( - step_id="user", + step_id=step_id, data_schema=schema, errors={"base": message_id}, description_placeholders={"address": f"{host}:{port}"}, ) + async def _async_from_host_or_form( + self, api_host: ApiHost, user_input: dict[str, Any], step_id: str + ) -> tuple[Box, None] | tuple[None, ConfigFlowResult]: + """Try to connect to the device; return product or an error form.""" + schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + try: + return await Box.async_from_host(api_host), None + except UnsupportedBoxVersion as ex: + return None, self.handle_step_exception( + ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id + ) + except UnauthorizedRequest as ex: + return None, self.handle_step_exception( + ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id + ) + except Error as ex: + return None, self.handle_step_exception( + ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id + ) + except RuntimeError as ex: + return None, self.handle_step_exception( + ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -145,12 +162,11 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle initial user-triggered config step.""" hass = self.hass - schema = create_schema(user_input) if user_input is None: return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=STEP_SCHEMA, errors={}, description_placeholders={}, ) @@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN): api_host = ApiHost( host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER ) - try: - product = await Box.async_from_host(api_host) - - except UnsupportedBoxVersion as ex: - return self.handle_step_exception( - "user", - ex, - schema, - host, - port, - UNSUPPORTED_VERSION, - _LOGGER.debug, - ) - except UnauthorizedRequest as ex: - return self.handle_step_exception( - "user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error - ) - - except Error as ex: - return self.handle_step_exception( - "user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning - ) - - except RuntimeError as ex: - return self.handle_step_exception( - "user", ex, schema, host, port, UNKNOWN, _LOGGER.error - ) + product, error = await self._async_from_host_or_form( + api_host, user_input, step_id="user" + ) + if error is not None: + return error + assert product is not None # Check if configured but IP changed since await self.async_set_unique_id(product.unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry(title=product.name, data=user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a BleBox device.""" + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is None: + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_SCHEMA, reconfigure_entry.data + ), + ) + + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + websession = get_maybe_authenticated_session(self.hass, password, username) + api_host = ApiHost( + host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER + ) + + product, error = await self._async_from_host_or_form( + api_host, user_input, step_id="reconfigure" + ) + if error is not None: + return error + assert product is not None + + await self.async_set_unique_id(product.unique_id, raise_on_progress=False) + self._abort_if_unique_id_mismatch() + + data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port} + if username is not None: + data_updates[CONF_USERNAME] = username + if password is not None: + data_updates[CONF_PASSWORD] = password + + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=data_updates, + ) diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index a67e3c051b23..a459d77f2e29 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "address_already_configured": "A BleBox device is already configured at {address}.", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device identifier does not match the previously configured device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,6 +13,16 @@ }, "flow_title": "{name} ({host})", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Update the connection settings for your BleBox device.", + "title": "Reconfigure BleBox device" + }, "user": { "data": { "host": "[%key:common::config_flow::data::ip%]", diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index 4abb7b474c31..890b4a2046ab 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -60,9 +60,11 @@ def mock_feature(category, spec, set_spec: bool = True, **kwargs): return feature_mock -def mock_config(ip_address="172.100.123.4"): +def mock_config(ip_address="172.100.123.4", unique_id="abcd0123ef5678"): """Return a Mock of the HA entity config.""" - return MockConfigEntry(domain=DOMAIN, data={CONF_HOST: ip_address, CONF_PORT: 80}) + return MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: ip_address, CONF_PORT: 80}, unique_id=unique_id + ) @pytest.fixture(name="config_entry") diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 12069b4d44ae..a4823b81d979 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -1,15 +1,15 @@ """Test Home Assistant config flow for BleBox devices.""" from ipaddress import ip_address -from unittest.mock import DEFAULT, AsyncMock, PropertyMock, patch +from unittest.mock import DEFAULT, AsyncMock, PropertyMock, create_autospec, patch import blebox_uniapi +import blebox_uniapi.box import pytest from homeassistant import config_entries from homeassistant.components.blebox import config_flow from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -250,14 +250,11 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: assert result2["data"] == {"host": "172.100.123.4", "port": 80} -async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) -> None: +async def test_flow_with_zeroconf_when_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test behaviour if device already configured.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, - data={CONF_IP_ADDRESS: "172.100.123.4"}, - unique_id="abcd0123ef5678", - ) - entry.add_to_hass(hass) + config_entry.add_to_hass(hass) feature: AsyncMock = mock_feature( "sensors", blebox_uniapi.sensor.Temperature, @@ -331,3 +328,116 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_device_response" + + +def create_product_mock(unique_id: str = "abcd0123ef5678"): + """Return a product mock with a given unique_id.""" + product = create_autospec(blebox_uniapi.box.Box, True, True) + type(product).unique_id = PropertyMock(return_value=unique_id) + return product + + +async def test_reconfigure_flow_works( + hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock +) -> None: + """Test that reconfigure flow updates host and port.""" + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("abcd0123ef5678") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + config_flow.CONF_HOST: "172.2.3.5", + config_flow.CONF_PORT: 80, + config_flow.CONF_USERNAME: "admin", + config_flow.CONF_PASSWORD: "secret", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[config_flow.CONF_HOST] == "172.2.3.5" + assert config_entry.data[config_flow.CONF_PORT] == 80 + assert config_entry.data[config_flow.CONF_USERNAME] == "admin" + assert config_entry.data[config_flow.CONF_PASSWORD] == "secret" + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock +) -> None: + """Test that reconfigure aborts when a different device is detected.""" + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("different_unique_id") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"), + pytest.param( + blebox_uniapi.error.UnauthorizedRequest, "cannot_connect", id="auth_failure" + ), + pytest.param( + blebox_uniapi.error.UnsupportedBoxVersion, + "unsupported_version", + id="unsupported_version", + ), + pytest.param(RuntimeError, "unknown", id="runtime_error"), + ], +) +async def test_reconfigure_flow_recovers_after_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + product_class_mock, + exception: type[Exception], + expected_error: str, +) -> None: + """Test that reconfigure shows the correct error for each exception type and allows a successful retry.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock(side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("abcd0123ef5678") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_HOST: "172.2.3.5", config_flow.CONF_PORT: 80}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful"