From e713632eed831f8b38b89060acc4c01ea6e451d9 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:28:10 +0200 Subject: [PATCH] Add reauth flow to Airobot integration (#157501) --- .../components/airobot/config_flow.py | 51 +++++++++++++ .../components/airobot/coordinator.py | 13 +++- .../components/airobot/manifest.json | 2 +- .../components/airobot/quality_scale.yaml | 2 +- homeassistant/components/airobot/strings.json | 22 +++++- tests/components/airobot/test_climate.py | 16 +++- tests/components/airobot/test_config_flow.py | 75 +++++++++++++++++++ tests/components/airobot/test_init.py | 22 +++++- 8 files changed, 193 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airobot/config_flow.py b/homeassistant/components/airobot/config_flow.py index 4daf95a903c..3bdd9273e82 100644 --- a/homeassistant/components/airobot/config_flow.py +++ b/homeassistant/components/airobot/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass import logging from typing import Any @@ -174,6 +175,56 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + # Combine existing data with new password + data = { + CONF_HOST: reauth_entry.data[CONF_HOST], + CONF_USERNAME: reauth_entry.data[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + + try: + await validate_input(self.hass, data) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={ + "username": reauth_entry.data[CONF_USERNAME], + "host": reauth_entry.data[CONF_HOST], + }, + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/airobot/coordinator.py b/homeassistant/components/airobot/coordinator.py index f1867adb842..617a564aa7c 100644 --- a/homeassistant/components/airobot/coordinator.py +++ b/homeassistant/components/airobot/coordinator.py @@ -11,6 +11,7 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -53,7 +54,15 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]): try: status = await self.client.get_statuses() settings = await self.client.get_settings() - except (AirobotAuthError, AirobotConnectionError) as err: - raise UpdateFailed(f"Failed to communicate with device: {err}") from err + except AirobotAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err + except AirobotConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_failed", + ) from err return AirobotData(status=status, settings=settings) diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json index a3546d14b0f..861f220b169 100644 --- a/homeassistant/components/airobot/manifest.json +++ b/homeassistant/components/airobot/manifest.json @@ -12,6 +12,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyairobotrest"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pyairobotrest==0.1.0"] } diff --git a/homeassistant/components/airobot/quality_scale.yaml b/homeassistant/components/airobot/quality_scale.yaml index e4e6eaeea10..1f7387ee44d 100644 --- a/homeassistant/components/airobot/quality_scale.yaml +++ b/homeassistant/components/airobot/quality_scale.yaml @@ -34,7 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index 15a7661c8db..ee5926b3c56 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -14,15 +15,24 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "The thermostat password." + "password": "[%key:component::airobot::config::step::user::data_description::password%]" }, "description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app." }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airobot::config::step::user::data_description::password%]" + }, + "description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "Device ID" }, "data_description": { "host": "The hostname or IP address of your Airobot thermostat.", @@ -34,6 +44,12 @@ } }, "exceptions": { + "authentication_failed": { + "message": "Authentication failed, please reauthenticate." + }, + "connection_failed": { + "message": "Failed to communicate with device." + }, "set_preset_mode_failed": { "message": "Failed to set preset mode to {preset_mode}." }, diff --git a/tests/components/airobot/test_climate.py b/tests/components/airobot/test_climate.py index 71a71c11ac3..759cce090b9 100644 --- a/tests/components/airobot/test_climate.py +++ b/tests/components/airobot/test_climate.py @@ -81,7 +81,9 @@ async def test_climate_set_temperature_error( """Test error handling when setting temperature fails.""" mock_airobot_client.set_home_temperature.side_effect = AirobotError("Device error") - with pytest.raises(ServiceValidationError, match="Failed to set temperature"): + with pytest.raises( + ServiceValidationError, match="Failed to set temperature" + ) as exc_info: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -92,6 +94,10 @@ async def test_climate_set_temperature_error( blocking=True, ) + assert exc_info.value.translation_domain == "airobot" + assert exc_info.value.translation_key == "set_temperature_failed" + assert exc_info.value.translation_placeholders == {"temperature": "24.0"} + @pytest.mark.parametrize( ("preset_mode", "method", "arg"), @@ -160,7 +166,9 @@ async def test_climate_set_preset_mode_error( """Test error handling when setting preset mode fails.""" mock_airobot_client.set_boost_mode.side_effect = AirobotError("Device error") - with pytest.raises(ServiceValidationError, match="Failed to set preset mode"): + with pytest.raises( + ServiceValidationError, match="Failed to set preset mode" + ) as exc_info: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -171,6 +179,10 @@ async def test_climate_set_preset_mode_error( blocking=True, ) + assert exc_info.value.translation_domain == "airobot" + assert exc_info.value.translation_key == "set_preset_mode_failed" + assert exc_info.value.translation_placeholders == {"preset_mode": "boost"} + async def test_climate_heating_state( hass: HomeAssistant, diff --git a/tests/components/airobot/test_config_flow.py b/tests/components/airobot/test_config_flow.py index e73bb8aede1..5a1b4978909 100644 --- a/tests/components/airobot/test_config_flow.py +++ b/tests/components/airobot/test_config_flow.py @@ -232,3 +232,78 @@ async def test_dhcp_discovery_duplicate( # Verify the IP was updated in the existing entry assert mock_config_entry.data[CONF_HOST] == "192.168.1.101" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airobot_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + # Trigger reauthentication + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"]["username"] == "T01A1B2C3" + assert result["description_placeholders"]["host"] == "192.168.1.100" + + # Provide new password + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error_base"), + [ + (AirobotAuthError("Invalid credentials"), "invalid_auth"), + (AirobotConnectionError("Connection failed"), "cannot_connect"), + (Exception("Unknown error"), "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airobot_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_base: str, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry.add_to_hass(hass) + + # Trigger reauthentication + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # First attempt with error + mock_airobot_client.get_statuses.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "wrong-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # Recover from error + mock_airobot_client.get_statuses.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/airobot/test_init.py b/tests/components/airobot/test_init.py index 9cffdc1bdfa..3e2b6712ada 100644 --- a/tests/components/airobot/test_init.py +++ b/tests/components/airobot/test_init.py @@ -26,7 +26,7 @@ async def test_setup_entry_success( @pytest.mark.parametrize( ("exception", "expected_state"), [ - (AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_RETRY), + (AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_ERROR), (AirobotConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY), ], ) @@ -48,6 +48,26 @@ async def test_setup_entry_exceptions( assert mock_config_entry.state is expected_state +async def test_setup_entry_auth_error_triggers_reauth( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup with auth error triggers reauth flow.""" + mock_config_entry.add_to_hass(hass) + + mock_airobot_client.get_statuses.side_effect = AirobotAuthError( + "Authentication failed" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + @pytest.mark.usefixtures("init_integration") async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry