diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 4e775e384fb..912b050a6b7 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info @@ -391,6 +391,60 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + if TYPE_CHECKING: + assert entry is not None + + self.host = entry.data[CONF_HOST] + self.port = entry.data.get(CONF_PORT, DEFAULT_HTTP_PORT) + self.entry = entry + + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors = {} + + if TYPE_CHECKING: + assert self.entry is not None + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_HTTP_PORT) + try: + info = await self._async_get_info(host, port) + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except CustomPortNotSupported: + errors["base"] = "custom_port_not_supported" + else: + if info[CONF_MAC] != self.entry.unique_id: + return self.async_abort(reason="another_device") + + data = {**self.entry.data, CONF_HOST: host, CONF_PORT: port} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self.host): str, + vol.Required(CONF_PORT, default=self.port): vol.Coerce(int), + } + ), + description_placeholders={"device_name": self.entry.title}, + errors=errors, + ) + async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: """Get info from shelly device.""" return await get_info(async_get_clientsession(self.hass), host, port=port) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cee27e9ca07..3a71874f2dd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -27,6 +27,17 @@ }, "confirm_discovery": { "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." + }, + "reconfigure_confirm": { + "description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::shelly::config::step::user::data_description::host%]", + "port": "[%key:component::shelly::config::step::user::data_description::port%]" + } } }, "error": { @@ -39,7 +50,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used." } }, "device_automation": { diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index c73b93f9fdb..f6467215faa 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_RECONFIGURE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -1187,3 +1187,120 @@ async def test_sleeping_device_gen2_with_new_firmware( "sleep_period": 666, "gen": 2, } + + +@pytest.mark.parametrize("gen", [1, 2, 3]) +async def test_reconfigure_successful( + hass: HomeAssistant, + gen: int, + mock_block_device: Mock, + mock_rpc_device: Mock, +) -> None: + """Test starting a reconfiguration flow.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {"host": "10.10.10.10", "port": 99, "gen": gen} + + +@pytest.mark.parametrize("gen", [1, 2, 3]) +async def test_reconfigure_unsuccessful( + hass: HomeAssistant, + gen: int, + mock_block_device: Mock, + mock_rpc_device: Mock, +) -> None: + """Test reconfiguration flow failed.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "another-mac", "type": MODEL_1, "auth": False, "gen": gen}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "another_device" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (DeviceConnectionError, "cannot_connect"), + (CustomPortNotSupported, "custom_port_not_supported"), + ], +) +async def test_reconfigure_with_exception( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_rpc_device: Mock, +) -> None: + """Test reconfiguration flow when an exception is raised.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["errors"] == {"base": base_error}