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
99 changes: 73 additions & 26 deletions homeassistant/components/nrgkick/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
import logging
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -119,6 +120,31 @@ def __init__(self) -> None:
self._discovered_name: str | None = None
self._pending_host: str | None = None

async def _async_validate_credentials(
self,
host: str,
errors: dict[str, str],
username: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
"""Validate credentials and populate errors dict on failure."""
try:
return await validate_input(
self.hass, host, username=username, password=password
)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
return None

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
Expand Down Expand Up @@ -169,36 +195,20 @@ async def async_step_user_auth(
assert self._pending_host is not None

if user_input is not None:
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)

try:
info = await validate_input(
self.hass,
self._pending_host,
username=username,
password=password,
)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info["title"],
data={
CONF_HOST: self._pending_host,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)

Expand All @@ -211,6 +221,42 @@ async def async_step_user_auth(
},
)

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle initiation of reauthentication."""
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication."""
errors: dict[str, str] = {}

if user_input is not None:
reauth_entry = self._get_reauth_entry()
if info := await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)

return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_AUTH_DATA_SCHEMA,
self._get_reauth_entry().data,
),
errors=errors,
)

async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
Expand All @@ -235,8 +281,9 @@ async def async_step_zeroconf(
# Store discovery info for the confirmation step.
self._discovered_host = discovery_info.host
# Fallback: device_name -> model_type -> "NRGkick".
self._discovered_name = device_name or model_type or "NRGkick"
self.context["title_placeholders"] = {"name": self._discovered_name}
discovered_name = device_name or model_type or "NRGkick"
self._discovered_name = discovered_name
self.context["title_placeholders"] = {"name": discovered_name}

# If JSON API is disabled, guide the user through enabling it.
if json_api_enabled != "1":
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/nrgkick/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
Expand Down Expand Up @@ -65,7 +65,7 @@ async def _async_update_data(self) -> NRGkickData:
control = await self.api.get_control()
values = await self.api.get_values(raw=True)
except NRGkickAuthenticationError as error:
raise ConfigEntryError(
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from error
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/nrgkick/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done

# Gold
Expand Down
15 changes: 14 additions & 1 deletion homeassistant/components/nrgkick/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"json_api_disabled": "JSON API is disabled on the device. Enable it in the NRGkick mobile app under Extended \u2192 Local API \u2192 API Variants.",
"no_serial_number": "Device does not provide a serial number"
"no_serial_number": "Device does not provide a serial number",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The device does not match the previous device"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
Expand All @@ -15,6 +17,17 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]",
"username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]"
},
"description": "Reauthenticate with your NRGkick device.\n\nGet your username and password in the NRGkick mobile app:\n1. Open the NRGkick mobile app \u2192 Extended \u2192 Local API\n2. Under Authentication (JSON), check or set your username and password"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/roborock/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.14.0",
"python-roborock==4.15.0",
"vacuum-map-parser-roborock==0.1.4"
]
}
2 changes: 1 addition & 1 deletion homeassistant/components/vicare/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.57.0"]
"requirements": ["PyViCare==2.58.0"]
}
4 changes: 2 additions & 2 deletions requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions tests/components/nrgkick/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,3 +674,104 @@ async def test_zeroconf_no_serial_number(hass: HomeAssistant) -> None:

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_serial_number"


@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
) -> None:
"""Test reauthentication flow."""
mock_config_entry.add_to_hass(hass)

result = await mock_config_entry.start_reauth_flow(hass)

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"

result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "new_user", CONF_PASSWORD: "new_pass"},
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.100"
assert mock_config_entry.data[CONF_USERNAME] == "new_user"
assert mock_config_entry.data[CONF_PASSWORD] == "new_pass"


@pytest.mark.parametrize(
("exception", "error"),
[
(NRGkickAPIDisabledError, "json_api_disabled"),
(NRGkickAuthenticationError, "invalid_auth"),
(NRGkickApiClientInvalidResponseError, "invalid_response"),
(NRGkickConnectionError, "cannot_connect"),
(NRGkickApiClientError, "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reauthentication flow error handling and recovery."""
mock_config_entry.add_to_hass(hass)

result = await mock_config_entry.start_reauth_flow(hass)

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"

mock_nrgkick_api.test_connection.side_effect = exception

result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
)

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": error}

mock_nrgkick_api.test_connection.side_effect = None

result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"


@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_flow_unique_id_mismatch(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
) -> None:
"""Test reauthentication aborts on unique ID mismatch."""
mock_config_entry.add_to_hass(hass)

result = await mock_config_entry.start_reauth_flow(hass)

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"

mock_nrgkick_api.get_info.return_value = {
"general": {"serial_number": "DIFFERENT123", "device_name": "Other"}
}

result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
Loading
Loading