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
12 changes: 5 additions & 7 deletions homeassistant/components/indevolt/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,8 @@ async def _async_setup(self) -> None:
"""Fetch device info once on boot."""
try:
config_data = await self.api.get_config()
except TimeoutError as err:
raise ConfigEntryNotReady(
f"Device config retrieval timed out: {err}"
) from err
except (ClientError, OSError) as err:
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err

# Cache device information
device_data = config_data.get("device", {})
Expand All @@ -93,16 +91,16 @@ async def _async_update_data(self) -> dict[str, Any]:

try:
return await self.api.fetch_data(sensor_keys)
except TimeoutError as err:
raise UpdateFailed(f"Device update timed out: {err}") from err
except (ClientError, OSError) as err:
raise UpdateFailed(f"Device update failed: {err}") from err

async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device."""
try:
return await self.api.set_data(sensor_key, value)
except TimeoutError as err:
raise DeviceTimeoutError(f"Device push timed out: {err}") from err
except (ClientError, ConnectionError, OSError) as err:
except (ClientError, OSError) as err:
raise DeviceConnectionError(f"Device push failed: {err}") from err

async def async_switch_energy_mode(
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/openevse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""The OpenEVSE integration."""

from openevsehttp.__main__ import OpenEVSE
from openevsehttp.exceptions import AuthenticationError

from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
Expand All @@ -25,6 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) ->
await charger.test_and_get()
except TimeoutError as ex:
raise ConfigEntryNotReady("Unable to connect to charger") from ex
except AuthenticationError as ex:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex

coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
await coordinator.async_config_entry_first_refresh()
Expand Down
36 changes: 36 additions & 0 deletions homeassistant/components/openevse/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Config flow for OpenEVSE integration."""

from collections.abc import Mapping
from typing import Any

from openevsehttp.__main__ import OpenEVSE
Expand Down Expand Up @@ -170,3 +171,38 @@ async def async_step_auth(
data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input),
errors=errors,
)

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication on an 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."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()

if user_input is not None:
errors, _ = await self.check_status(
reauth_entry.data[CONF_HOST],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
if not errors:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)

return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input),
description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]},
errors=errors,
)
4 changes: 4 additions & 0 deletions homeassistant/components/openevse/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import logging

from openevsehttp.__main__ import OpenEVSE
from openevsehttp.exceptions import AuthenticationError

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

from .const import DOMAIN
Expand Down Expand Up @@ -63,3 +65,5 @@ async def _async_update_data(self) -> None:
raise UpdateFailed(
f"Timeout communicating with charger: {error}"
) from error
except AuthenticationError as error:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from error
12 changes: 12 additions & 0 deletions homeassistant/components/openevse/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "This charger is already configured",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unavailable_host": "Unable to connect to host"
},
"error": {
Expand All @@ -19,6 +20,17 @@
"username": "The username to access your OpenEVSE charger"
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::openevse::config::step::auth::data_description::password%]",
"username": "[%key:component::openevse::config::step::auth::data_description::username%]"
},
"description": "The credentials for your OpenEVSE charger at {host} are no longer valid. Please enter your current username and password."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
Expand Down
9 changes: 4 additions & 5 deletions tests/components/indevolt/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,11 +371,11 @@ async def test_single_device_execution_failure(
mock_indevolt: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that the original exception is re-raised for a single device execution failure."""
"""Test that the exception is raised for a single device execution failure."""
await setup_integration(hass, mock_config_entry)

# Simulate an API push failure
mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed")
mock_indevolt.set_data.side_effect = OSError("Device push failed")

# Mock call to start charging
with pytest.raises(HomeAssistantError) as exc_info:
Expand All @@ -391,8 +391,7 @@ async def test_single_device_execution_failure(
)

# Verify correct translation key is used for the error (for single coordinator)
assert str(exc_info.value) == "Device push failed"
assert exc_info.value.translation_key is None
assert exc_info.value.translation_key == "failed_to_switch_energy_mode"


@pytest.mark.parametrize("generation", [2], indirect=True)
Expand All @@ -410,7 +409,7 @@ async def test_multi_device_execution_failure(
await setup_integration(hass, alt_mock_config_entry)

# Simulate an API push failure (triggers for both coordinators)
mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed")
mock_indevolt.set_data.side_effect = OSError("Device push failed")

# Mock call to start charging both devices
with pytest.raises(HomeAssistantError) as exc_info:
Expand Down
78 changes: 78 additions & 0 deletions tests/components/openevse/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import AsyncMock, MagicMock

from openevsehttp.exceptions import AuthenticationError, MissingSerial
import pytest

from homeassistant.components.openevse.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF
Expand Down Expand Up @@ -486,3 +487,80 @@ async def test_zeroconf_already_configured_host(
# Should abort because the host matches an existing entry
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"


async def test_reauth_flow(
hass: HomeAssistant,
mock_charger: MagicMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> 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"
assert result["description_placeholders"][CONF_HOST] == "192.168.1.100"

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

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == {
CONF_HOST: "192.168.1.100",
CONF_USERNAME: "newuser",
CONF_PASSWORD: "newpassword",
}


@pytest.mark.parametrize(
("exception", "error_base"),
[
(AuthenticationError, "invalid_auth"),
(TimeoutError, "cannot_connect"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_charger: MagicMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error_base: str,
) -> None:
"""Test reauthentication flow recovers from errors."""
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_charger.test_and_get.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "newuser", CONF_PASSWORD: "wrongpassword"},
)

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

mock_charger.test_and_get.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "newuser", CONF_PASSWORD: "newpassword"},
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == {
CONF_HOST: "192.168.1.100",
CONF_USERNAME: "newuser",
CONF_PASSWORD: "newpassword",
}
47 changes: 46 additions & 1 deletion tests/components/openevse/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from unittest.mock import MagicMock

from homeassistant.config_entries import ConfigEntryState
from openevsehttp.exceptions import AuthenticationError

from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant

from tests.common import MockConfigEntry
Expand All @@ -23,6 +25,49 @@ async def test_setup_entry_timeout(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY


async def test_setup_entry_auth_error_starts_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
) -> None:
"""Test setup entry triggers reauth on authentication error."""
mock_charger.test_and_get.side_effect = AuthenticationError

mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id


async def test_coordinator_update_auth_error_starts_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
) -> None:
"""Test coordinator update triggers reauth on authentication error."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

assert mock_config_entry.state is ConfigEntryState.LOADED

coordinator = mock_config_entry.runtime_data
mock_charger.update.side_effect = AuthenticationError
await coordinator.async_refresh()
await hass.async_block_till_done()

flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id


async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
Expand Down
Loading