Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions homeassistant/components/airobot/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
Expand Down Expand Up @@ -174,6 +175,56 @@ async def async_step_user(
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."""
Expand Down
13 changes: 11 additions & 2 deletions homeassistant/components/airobot/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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

Expand Down Expand Up @@ -53,7 +54,15 @@ async def _async_update_data(self) -> 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)
2 changes: 1 addition & 1 deletion homeassistant/components/airobot/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyairobotrest==0.1.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/airobot/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions homeassistant/components/airobot/strings.json
Original file line number Diff line number Diff line change
@@ -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%]",
Expand All @@ -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"
Comment thread
mettolen marked this conversation as resolved.
},
"data_description": {
"host": "The hostname or IP address of your Airobot thermostat.",
Expand All @@ -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}."
},
Expand Down
16 changes: 14 additions & 2 deletions tests/components/airobot/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions tests/components/airobot/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
22 changes: 21 additions & 1 deletion tests/components/airobot/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
)
Expand All @@ -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
Expand Down
Loading