diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index daf0fbd32b7b..df680abe36e5 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,7 +15,12 @@ from gotailwind import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -143,6 +148,46 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing Tailwind device.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=reconfigure_entry.data[CONF_HOST], + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -219,6 +264,17 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + if self.source == SOURCE_RECONFIGURE: + await self.async_set_unique_id(format_mac(status.mac_address)) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: host, + CONF_TOKEN: token, + }, + ) + await self.async_set_unique_id( format_mac(status.mac_address), raise_on_progress=False ) diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml index 4f4b84187c65..77d5d8711f4f 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -60,7 +60,7 @@ rules: comment: | The coordinator needs translation when the update failed. icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 8cb059a74d09..0e4850c701e9 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The entered information is for a different Tailwind device.", "no_device_id": "The discovered Tailwind device did not provide a device ID.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." }, @@ -23,6 +25,17 @@ }, "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below." }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "host": "[%key:component::tailwind::config::step::user::data_description::host%]", + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + }, + "description": "Reconfigure your Tailwind garage door opener.\n\nThis allows you to change the IP address and local control key of your Tailwind device." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index 2e8a8e7a727b..872e278d1450 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -393,6 +393,108 @@ async def test_reauth_flow_errors( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_tailwind") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfiguration flow updates an existing entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.data[CONF_HOST] == "127.0.0.42" + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test the reconfiguration flow recovers from errors.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_error + + mock_tailwind.status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test reconfigure aborts when the new device has a different MAC.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.return_value = MagicMock( + mac_address="aa:bb:cc:dd:ee:ff", + product="iQ3", + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "different_device" + + async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry,