diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 25a26fc92df..351cae61b1d 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -40,12 +41,6 @@ APPS_NEW_ID = "NewApp" CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required("host"): str, - } -) - STEP_PAIR_DATA_SCHEMA = vol.Schema( { vol.Required("pin"): str, @@ -66,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial and reconfigure step.""" errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] @@ -75,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): await api.async_generate_cert_if_missing() self.name, self.mac = await api.async_get_name_and_mac() await self.async_set_unique_id(format_mac(self.mac)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): # Likely invalid IP address or device is network unreachable. Stay # in the user step allowing the user to enter a different host. errors["base"] = "cannot_connect" + else: + user_input = {} + default_host = user_input.get(CONF_HOST, vol.UNDEFINED) + if self.source == SOURCE_RECONFIGURE: + default_host = self._get_reconfigure_entry().data[CONF_HOST] return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), errors=errors, ) @@ -216,6 +228,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 7130c5b2b3b..d0eb1d0dca4 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -11,6 +11,15 @@ "host": "The hostname or IP address of the Android TV device." } }, + "reconfigure": { + "description": "Update the IP address of this previously configured Android TV device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." + } + }, "zeroconf_confirm": { "title": "Discovered Android TV", "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." @@ -38,7 +47,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "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%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." } }, "options": { diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 0968ea5acff..9652ac0c3a9 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1069,3 +1069,100 @@ async def test_options_flow( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_config_entry.options == {CONF_ENABLE_IME: True} + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reconfigure flow from start to finish without any exceptions.""" + 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" + assert not result["errors"] + assert "host" in result["data_schema"].schema + # Form should have as default value the existing host + host_key = next(k for k in result["data_schema"].schema if k.schema == "host") + assert host_key.default() == mock_config_entry.data["host"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock( + return_value=(mock_config_entry.data["name"], mock_config_entry.data["mac"]) + ) + + # Simulate user input with a new host + new_host = "4.3.2.1" + assert new_host != mock_config_entry.data["host"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data["host"] == new_host + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with CannotConnect exception.""" + 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" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with a different device (unique_id mismatch).""" + 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" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + # The new host corresponds to a device with a different MAC/unique_id + new_mac = "FF:EE:DD:CC:BB:AA" + assert new_mac != mock_config_entry.data["mac"] + mock_api.async_get_name_and_mac = AsyncMock(return_value=("name", new_mac)) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0