Skip to content

Commit e713632

Browse files
authored
Add reauth flow to Airobot integration (home-assistant#157501)
1 parent 060ad35 commit e713632

File tree

8 files changed

+193
-10
lines changed

8 files changed

+193
-10
lines changed

homeassistant/components/airobot/config_flow.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
from dataclasses import dataclass
67
import logging
78
from typing import Any
@@ -174,6 +175,56 @@ async def async_step_user(
174175
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
175176
)
176177

178+
async def async_step_reauth(
179+
self, entry_data: Mapping[str, Any]
180+
) -> ConfigFlowResult:
181+
"""Handle reauthentication upon an API authentication error."""
182+
return await self.async_step_reauth_confirm()
183+
184+
async def async_step_reauth_confirm(
185+
self, user_input: dict[str, Any] | None = None
186+
) -> ConfigFlowResult:
187+
"""Confirm reauthentication dialog."""
188+
errors: dict[str, str] = {}
189+
reauth_entry = self._get_reauth_entry()
190+
191+
if user_input is not None:
192+
# Combine existing data with new password
193+
data = {
194+
CONF_HOST: reauth_entry.data[CONF_HOST],
195+
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
196+
CONF_PASSWORD: user_input[CONF_PASSWORD],
197+
}
198+
199+
try:
200+
await validate_input(self.hass, data)
201+
except CannotConnect:
202+
errors["base"] = "cannot_connect"
203+
except InvalidAuth:
204+
errors["base"] = "invalid_auth"
205+
except Exception:
206+
_LOGGER.exception("Unexpected exception")
207+
errors["base"] = "unknown"
208+
else:
209+
return self.async_update_reload_and_abort(
210+
reauth_entry,
211+
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
212+
)
213+
214+
return self.async_show_form(
215+
step_id="reauth_confirm",
216+
data_schema=vol.Schema(
217+
{
218+
vol.Required(CONF_PASSWORD): str,
219+
}
220+
),
221+
description_placeholders={
222+
"username": reauth_entry.data[CONF_USERNAME],
223+
"host": reauth_entry.data[CONF_HOST],
224+
},
225+
errors=errors,
226+
)
227+
177228

178229
class CannotConnect(HomeAssistantError):
179230
"""Error to indicate we cannot connect."""

homeassistant/components/airobot/coordinator.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from homeassistant.config_entries import ConfigEntry
1212
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
1313
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import ConfigEntryAuthFailed
1415
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1516
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1617

@@ -53,7 +54,15 @@ async def _async_update_data(self) -> AirobotData:
5354
try:
5455
status = await self.client.get_statuses()
5556
settings = await self.client.get_settings()
56-
except (AirobotAuthError, AirobotConnectionError) as err:
57-
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
57+
except AirobotAuthError as err:
58+
raise ConfigEntryAuthFailed(
59+
translation_domain=DOMAIN,
60+
translation_key="authentication_failed",
61+
) from err
62+
except AirobotConnectionError as err:
63+
raise UpdateFailed(
64+
translation_domain=DOMAIN,
65+
translation_key="connection_failed",
66+
) from err
5867

5968
return AirobotData(status=status, settings=settings)

homeassistant/components/airobot/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
"integration_type": "device",
1313
"iot_class": "local_polling",
1414
"loggers": ["pyairobotrest"],
15-
"quality_scale": "bronze",
15+
"quality_scale": "silver",
1616
"requirements": ["pyairobotrest==0.1.0"]
1717
}

homeassistant/components/airobot/quality_scale.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ rules:
3434
integration-owner: done
3535
log-when-unavailable: done
3636
parallel-updates: done
37-
reauthentication-flow: todo
37+
reauthentication-flow: done
3838
test-coverage: done
3939

4040
# Gold

homeassistant/components/airobot/strings.json

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"config": {
33
"abort": {
4-
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
4+
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
5+
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
56
},
67
"error": {
78
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -14,15 +15,24 @@
1415
"password": "[%key:common::config_flow::data::password%]"
1516
},
1617
"data_description": {
17-
"password": "The thermostat password."
18+
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
1819
},
1920
"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."
2021
},
22+
"reauth_confirm": {
23+
"data": {
24+
"password": "[%key:common::config_flow::data::password%]"
25+
},
26+
"data_description": {
27+
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
28+
},
29+
"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."
30+
},
2131
"user": {
2232
"data": {
2333
"host": "[%key:common::config_flow::data::host%]",
2434
"password": "[%key:common::config_flow::data::password%]",
25-
"username": "[%key:common::config_flow::data::username%]"
35+
"username": "Device ID"
2636
},
2737
"data_description": {
2838
"host": "The hostname or IP address of your Airobot thermostat.",
@@ -34,6 +44,12 @@
3444
}
3545
},
3646
"exceptions": {
47+
"authentication_failed": {
48+
"message": "Authentication failed, please reauthenticate."
49+
},
50+
"connection_failed": {
51+
"message": "Failed to communicate with device."
52+
},
3753
"set_preset_mode_failed": {
3854
"message": "Failed to set preset mode to {preset_mode}."
3955
},

tests/components/airobot/test_climate.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ async def test_climate_set_temperature_error(
8181
"""Test error handling when setting temperature fails."""
8282
mock_airobot_client.set_home_temperature.side_effect = AirobotError("Device error")
8383

84-
with pytest.raises(ServiceValidationError, match="Failed to set temperature"):
84+
with pytest.raises(
85+
ServiceValidationError, match="Failed to set temperature"
86+
) as exc_info:
8587
await hass.services.async_call(
8688
CLIMATE_DOMAIN,
8789
SERVICE_SET_TEMPERATURE,
@@ -92,6 +94,10 @@ async def test_climate_set_temperature_error(
9294
blocking=True,
9395
)
9496

97+
assert exc_info.value.translation_domain == "airobot"
98+
assert exc_info.value.translation_key == "set_temperature_failed"
99+
assert exc_info.value.translation_placeholders == {"temperature": "24.0"}
100+
95101

96102
@pytest.mark.parametrize(
97103
("preset_mode", "method", "arg"),
@@ -160,7 +166,9 @@ async def test_climate_set_preset_mode_error(
160166
"""Test error handling when setting preset mode fails."""
161167
mock_airobot_client.set_boost_mode.side_effect = AirobotError("Device error")
162168

163-
with pytest.raises(ServiceValidationError, match="Failed to set preset mode"):
169+
with pytest.raises(
170+
ServiceValidationError, match="Failed to set preset mode"
171+
) as exc_info:
164172
await hass.services.async_call(
165173
CLIMATE_DOMAIN,
166174
SERVICE_SET_PRESET_MODE,
@@ -171,6 +179,10 @@ async def test_climate_set_preset_mode_error(
171179
blocking=True,
172180
)
173181

182+
assert exc_info.value.translation_domain == "airobot"
183+
assert exc_info.value.translation_key == "set_preset_mode_failed"
184+
assert exc_info.value.translation_placeholders == {"preset_mode": "boost"}
185+
174186

175187
async def test_climate_heating_state(
176188
hass: HomeAssistant,

tests/components/airobot/test_config_flow.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,78 @@ async def test_dhcp_discovery_duplicate(
232232

233233
# Verify the IP was updated in the existing entry
234234
assert mock_config_entry.data[CONF_HOST] == "192.168.1.101"
235+
236+
237+
async def test_reauth_flow(
238+
hass: HomeAssistant,
239+
mock_setup_entry: AsyncMock,
240+
mock_airobot_client: AsyncMock,
241+
mock_config_entry: MockConfigEntry,
242+
) -> None:
243+
"""Test reauthentication flow."""
244+
mock_config_entry.add_to_hass(hass)
245+
246+
# Trigger reauthentication
247+
result = await mock_config_entry.start_reauth_flow(hass)
248+
249+
assert result["type"] is FlowResultType.FORM
250+
assert result["step_id"] == "reauth_confirm"
251+
assert result["description_placeholders"]["username"] == "T01A1B2C3"
252+
assert result["description_placeholders"]["host"] == "192.168.1.100"
253+
254+
# Provide new password
255+
result = await hass.config_entries.flow.async_configure(
256+
result["flow_id"],
257+
{CONF_PASSWORD: "new-password"},
258+
)
259+
260+
assert result["type"] is FlowResultType.ABORT
261+
assert result["reason"] == "reauth_successful"
262+
assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
263+
264+
265+
@pytest.mark.parametrize(
266+
("exception", "error_base"),
267+
[
268+
(AirobotAuthError("Invalid credentials"), "invalid_auth"),
269+
(AirobotConnectionError("Connection failed"), "cannot_connect"),
270+
(Exception("Unknown error"), "unknown"),
271+
],
272+
)
273+
async def test_reauth_flow_errors(
274+
hass: HomeAssistant,
275+
mock_setup_entry: AsyncMock,
276+
mock_airobot_client: AsyncMock,
277+
mock_config_entry: MockConfigEntry,
278+
exception: Exception,
279+
error_base: str,
280+
) -> None:
281+
"""Test reauthentication flow with errors."""
282+
mock_config_entry.add_to_hass(hass)
283+
284+
# Trigger reauthentication
285+
result = await mock_config_entry.start_reauth_flow(hass)
286+
287+
assert result["type"] is FlowResultType.FORM
288+
assert result["step_id"] == "reauth_confirm"
289+
290+
# First attempt with error
291+
mock_airobot_client.get_statuses.side_effect = exception
292+
result = await hass.config_entries.flow.async_configure(
293+
result["flow_id"],
294+
{CONF_PASSWORD: "wrong-password"},
295+
)
296+
297+
assert result["type"] is FlowResultType.FORM
298+
assert result["errors"] == {"base": error_base}
299+
300+
# Recover from error
301+
mock_airobot_client.get_statuses.side_effect = None
302+
result = await hass.config_entries.flow.async_configure(
303+
result["flow_id"],
304+
{CONF_PASSWORD: "new-password"},
305+
)
306+
307+
assert result["type"] is FlowResultType.ABORT
308+
assert result["reason"] == "reauth_successful"
309+
assert mock_config_entry.data[CONF_PASSWORD] == "new-password"

tests/components/airobot/test_init.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ async def test_setup_entry_success(
2626
@pytest.mark.parametrize(
2727
("exception", "expected_state"),
2828
[
29-
(AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_RETRY),
29+
(AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_ERROR),
3030
(AirobotConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY),
3131
],
3232
)
@@ -48,6 +48,26 @@ async def test_setup_entry_exceptions(
4848
assert mock_config_entry.state is expected_state
4949

5050

51+
async def test_setup_entry_auth_error_triggers_reauth(
52+
hass: HomeAssistant,
53+
mock_airobot_client: AsyncMock,
54+
mock_config_entry: MockConfigEntry,
55+
) -> None:
56+
"""Test setup with auth error triggers reauth flow."""
57+
mock_config_entry.add_to_hass(hass)
58+
59+
mock_airobot_client.get_statuses.side_effect = AirobotAuthError(
60+
"Authentication failed"
61+
)
62+
63+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
64+
await hass.async_block_till_done()
65+
66+
flows = hass.config_entries.flow.async_progress()
67+
assert len(flows) == 1
68+
assert flows[0]["step_id"] == "reauth_confirm"
69+
70+
5171
@pytest.mark.usefixtures("init_integration")
5272
async def test_unload_entry(
5373
hass: HomeAssistant, mock_config_entry: MockConfigEntry

0 commit comments

Comments
 (0)