diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 4afc544cc1d..398788f1f9f 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -2,12 +2,16 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta +from typing import Any, Concatenate from peblar import ( Peblar, PeblarApi, + PeblarAuthenticationError, + PeblarConnectionError, PeblarError, PeblarEVInterface, PeblarMeter, @@ -16,12 +20,13 @@ from peblar import ( PeblarVersions, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from tests.components.peblar.conftest import PeblarSystemInformation -from .const import LOGGER +from .const import DOMAIN, LOGGER @dataclass(kw_only=True) @@ -59,6 +64,49 @@ class PeblarData: system: PeblarSystem +def _coordinator_exception_handler[ + _DataUpdateCoordinatorT: PeblarDataUpdateCoordinator + | PeblarVersionDataUpdateCoordinator + | PeblarUserConfigurationDataUpdateCoordinator, + **_P, +]( + func: Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]]: + """Handle exceptions within the update handler of a coordinator.""" + + async def handler( + self: _DataUpdateCoordinatorT, *args: _P.args, **kwargs: _P.kwargs + ) -> Any: + try: + return await func(self, *args, **kwargs) + except PeblarAuthenticationError as error: + if self.config_entry and self.config_entry.state is ConfigEntryState.LOADED: + # This is not the first refresh, so let's reload + # the config entry to ensure we trigger a re-authentication + # flow (or recover in case of API token changes). + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error + except PeblarConnectionError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + except PeblarError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler + + class PeblarVersionDataUpdateCoordinator( DataUpdateCoordinator[PeblarVersionInformation] ): @@ -77,15 +125,13 @@ class PeblarVersionDataUpdateCoordinator( update_interval=timedelta(hours=2), ) + @_coordinator_exception_handler async def _async_update_data(self) -> PeblarVersionInformation: """Fetch data from the Peblar device.""" - try: - return PeblarVersionInformation( - current=await self.peblar.current_versions(), - available=await self.peblar.available_versions(), - ) - except PeblarError as err: - raise UpdateFailed(err) from err + return PeblarVersionInformation( + current=await self.peblar.current_versions(), + available=await self.peblar.available_versions(), + ) class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): @@ -104,16 +150,14 @@ class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): update_interval=timedelta(seconds=10), ) + @_coordinator_exception_handler async def _async_update_data(self) -> PeblarData: """Fetch data from the Peblar device.""" - try: - return PeblarData( - ev=await self.api.ev_interface(), - meter=await self.api.meter(), - system=await self.api.system(), - ) - except PeblarError as err: - raise UpdateFailed(err) from err + return PeblarData( + ev=await self.api.ev_interface(), + meter=await self.api.meter(), + system=await self.api.system(), + ) class PeblarUserConfigurationDataUpdateCoordinator( @@ -134,9 +178,7 @@ class PeblarUserConfigurationDataUpdateCoordinator( update_interval=timedelta(minutes=5), ) + @_coordinator_exception_handler async def _async_update_data(self) -> PeblarUserConfiguration: """Fetch data from the Peblar device.""" - try: - return await self.peblar.user_configuration() - except PeblarError as err: - raise UpdateFailed(err) from err + return await self.peblar.user_configuration() diff --git a/tests/components/peblar/test_coordinator.py b/tests/components/peblar/test_coordinator.py new file mode 100644 index 00000000000..f438d807920 --- /dev/null +++ b/tests/components/peblar/test_coordinator.py @@ -0,0 +1,119 @@ +"""Tests for the Peblar coordinators.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True), + pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration"), +] + + +@pytest.mark.parametrize( + ("error", "log_message"), + [ + ( + PeblarConnectionError("Could not connect"), + ( + "An error occurred while communicating with the Peblar device: " + "Could not connect" + ), + ), + ( + PeblarError("Unknown error"), + ( + "An unknown error occurred while communicating " + "with the Peblar device: Unknown error" + ), + ), + ], +) +async def test_coordinator_error_handler( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + error: Exception, + log_message: str, +) -> None: + """Test the coordinators.""" + entity_id = "sensor.peblar_ev_charger_power" + + # Ensure we are set up and the coordinator is working. + # Confirming this through a sensor entity, that is available. + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE + + # Mock an error in the coordinator. + mock_peblar.rest_api.return_value.meter.side_effect = error + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now unavailable. + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + # Ensure the error is logged + assert log_message in caplog.text + + # Recover + mock_peblar.rest_api.return_value.meter.side_effect = None + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now available. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state != STATE_UNAVAILABLE + + +async def test_coordinator_error_handler_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator error handler with an authentication error.""" + + # Ensure the sensor entity is now available. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state != STATE_UNAVAILABLE + + # Mock an authentication in the coordinator + mock_peblar.rest_api.return_value.meter.side_effect = PeblarAuthenticationError( + "Authentication error" + ) + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now unavailable. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state == STATE_UNAVAILABLE + + # Ensure we have triggered a reauthentication flow + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id