Add coordinator error handling for Peblar Rocksolid EV Chargers (#133809)

This commit is contained in:
Franck Nijhof 2024-12-23 11:11:25 +01:00 committed by GitHub
parent 83f5ca5a30
commit ed7da35de4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 182 additions and 21 deletions

View File

@ -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()

View File

@ -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