From d8cca482b3726f2ad9f0914f1efbc14754d90a70 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 24 Apr 2024 07:52:14 +0200 Subject: [PATCH] Add reconfigure flow to AVM Fritz!Tools (#116057) add reconfigure flow --- homeassistant/components/fritz/config_flow.py | 84 ++++++++- homeassistant/components/fritz/strings.json | 16 +- tests/components/fritz/test_config_flow.py | 177 +++++++++++++++++- 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 1cfa3af39fb..fdafd486b29 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -138,6 +138,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + def _determine_port(self, user_input: dict[str, Any]) -> int: + """Determine port from user_input.""" + if port := user_input.get(CONF_PORT): + return int(port) + return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT + async def async_step_ssdp( self, discovery_info: ssdp.SsdpServiceInfo ) -> ConfigFlowResult: @@ -189,7 +195,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._use_tls = user_input[CONF_SSL] - self._port = DEFAULT_HTTPS_PORT if self._use_tls else DEFAULT_HTTP_PORT + self._port = self._determine_port(user_input) error = await self.hass.async_add_executor_job(self.fritz_tools_init) @@ -252,10 +258,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._password = user_input[CONF_PASSWORD] self._use_tls = user_input[CONF_SSL] - if (port := user_input.get(CONF_PORT)) is None: - self._port = DEFAULT_HTTPS_PORT if self._use_tls else DEFAULT_HTTP_PORT - else: - self._port = port + self._port = self._determine_port(user_input) if not (error := await self.hass.async_add_executor_job(self.fritz_tools_init)): self._name = self._model @@ -329,6 +332,77 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self._entry.entry_id) return self.async_abort(reason="reauth_successful") + async def async_step_reconfigure(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reconfigure flow .""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert self._entry + self._host = self._entry.data[CONF_HOST] + self._port = self._entry.data[CONF_PORT] + self._username = self._entry.data[CONF_USERNAME] + self._password = self._entry.data[CONF_PASSWORD] + self._use_tls = self._entry.data.get(CONF_SSL, DEFAULT_SSL) + + return await self.async_step_reconfigure_confirm() + + def _show_setup_form_reconfigure_confirm( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Show the reconfigure form to the user.""" + advanced_data_schema = {} + if self.show_advanced_options: + advanced_data_schema = { + vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(int), + } + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, + **advanced_data_schema, + vol.Required(CONF_SSL, default=user_input[CONF_SSL]): bool, + } + ), + description_placeholders={"host": self._host}, + errors=errors or {}, + ) + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + if user_input is None: + return self._show_setup_form_reconfigure_confirm( + { + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_SSL: self._use_tls, + } + ) + + self._host = user_input[CONF_HOST] + self._use_tls = user_input[CONF_SSL] + self._port = self._determine_port(user_input) + + if error := await self.hass.async_add_executor_job(self.fritz_tools_init): + return self._show_setup_form_reconfigure_confirm( + user_input={**user_input, CONF_PORT: self._port}, errors={"base": error} + ) + + assert isinstance(self._entry, ConfigEntry) + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_HOST: self._host, + CONF_PASSWORD: self._password, + CONF_PORT: self._port, + CONF_USERNAME: self._username, + CONF_SSL: self._use_tls, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): """Handle an options flow.""" diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 4899edb6938..a96c3b8ac28 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -18,6 +18,19 @@ "password": "[%key:common::config_flow::data::password%]" } }, + "reconfigure_confirm": { + "title": "Updating FRITZ!Box Tools - configuration", + "description": "Update FRITZ!Box Tools configuration for: {host}.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router.", + "port": "Leave it empty to use the default port." + } + }, "user": { "title": "[%key:component::fritz::config::step::confirm::title%]", "description": "Set up FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", @@ -38,7 +51,8 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "ignore_ip6_link_local": "IPv6 link local address is not supported.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 64bf3cd9064..f87fbe722cd 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -23,7 +23,12 @@ from homeassistant.components.fritz.const import ( FRITZ_AUTH_EXCEPTIONS, ) from homeassistant.components.ssdp import ATTR_UPNP_UDN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_SSDP, + SOURCE_USER, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -405,6 +410,176 @@ async def test_reauth_not_successful( assert result["errors"]["base"] == error +@pytest.mark.parametrize( + ("show_advanced_options", "user_input", "expected_config"), + [ + ( + True, + {CONF_HOST: "host_a", CONF_PORT: 49000, CONF_SSL: False}, + {CONF_HOST: "host_a", CONF_PORT: 49000, CONF_SSL: False}, + ), + ( + True, + {CONF_HOST: "host_a", CONF_PORT: 49443, CONF_SSL: True}, + {CONF_HOST: "host_a", CONF_PORT: 49443, CONF_SSL: True}, + ), + ( + True, + {CONF_HOST: "host_a", CONF_PORT: 12345, CONF_SSL: True}, + {CONF_HOST: "host_a", CONF_PORT: 12345, CONF_SSL: True}, + ), + ( + False, + {CONF_HOST: "host_b", CONF_SSL: False}, + {CONF_HOST: "host_b", CONF_PORT: 49000, CONF_SSL: False}, + ), + ( + False, + {CONF_HOST: "host_b", CONF_SSL: True}, + {CONF_HOST: "host_b", CONF_PORT: 49443, CONF_SSL: True}, + ), + ], +) +async def test_reconfigure_successful( + hass: HomeAssistant, + fc_class_mock, + mock_get_source_ip, + show_advanced_options: bool, + user_input: dict, + expected_config: dict, +) -> None: + """Test starting a reconfigure flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + side_effect=fc_class_mock, + ), + patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), + patch( + "homeassistant.components.fritz.async_setup_entry", + ) as mock_setup_entry, + patch( + "requests.get", + ) as mock_request_get, + patch( + "requests.post", + ) as mock_request_post, + ): + mock_request_get.return_value.status_code = 200 + mock_request_get.return_value.content = MOCK_REQUEST + mock_request_post.return_value.status_code = 200 + mock_request_post.return_value.text = MOCK_REQUEST + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + "show_advanced_options": show_advanced_options, + }, + data=mock_config.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config.data == { + **expected_config, + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + } + + assert mock_setup_entry.called + + +async def test_reconfigure_not_successful( + hass: HomeAssistant, + fc_class_mock, + mock_get_source_ip, +) -> None: + """Test starting a reconfigure flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + side_effect=[FritzConnectionException, fc_class_mock], + ), + patch( + "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + return_value=MOCK_FIRMWARE_INFO, + ), + patch( + "homeassistant.components.fritz.async_setup_entry", + ), + patch( + "requests.get", + ) as mock_request_get, + patch( + "requests.post", + ) as mock_request_post, + ): + mock_request_get.return_value.status_code = 200 + mock_request_get.return_value.content = MOCK_REQUEST + mock_request_post.return_value.status_code = 200 + mock_request_post.return_value.text = MOCK_REQUEST + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"]["base"] == ERROR_CANNOT_CONNECT + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config.data == { + CONF_HOST: "fake_host", + CONF_PASSWORD: "fake_pass", + CONF_USERNAME: "fake_user", + CONF_PORT: 49000, + CONF_SSL: False, + } + + async def test_ssdp_already_configured( hass: HomeAssistant, fc_class_mock, mock_get_source_ip ) -> None: