Add error handling to enphase_envoy switch platform action (#136837)

* Add error handling to enphase_envoy switch platform action

* Use decorators for exception handling
pull/132676/head^2
Arie Catsman 2025-01-30 10:07:10 +01:00 committed by GitHub
parent a8175b785f
commit 97fcbed6e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 183 additions and 2 deletions

View File

@ -2,13 +2,22 @@
from __future__ import annotations
from pyenphase import EnvoyData
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from httpx import HTTPError
from pyenphase import EnvoyData
from pyenphase.exceptions import EnvoyError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
ACTIONERRORS = (EnvoyError, HTTPError)
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
"""Defines a base envoy entity."""
@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
data = self.coordinator.envoy.data
assert data is not None
return data
def exception_handler[_EntityT: EnvoyBaseEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Enphase Envoy calls to handle exceptions.
A decorator that wraps the passed in function, catches enphase_envoy errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except ACTIONERRORS as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_error",
translation_placeholders={
"host": self.coordinator.envoy.host,
"args": error.args[0],
"action": func.__name__,
"entity": self.entity_id,
},
) from error
return handler

View File

@ -400,6 +400,9 @@
},
"envoy_error": {
"message": "Error communicating with Envoy API on {host}: {args}"
},
"action_error": {
"message": "Failed to execute {action} for {entity}, host: {host}: {args}"
}
}
}

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert enpower is not None
return self.entity_description.value_fn(enpower)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Enpower switch."""
await self.entity_description.turn_on_fn(self.envoy)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the Enpower switch."""
await self.entity_description.turn_off_fn(self.envoy)
@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert relay is not None
return self.entity_description.value_fn(relay)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on (close) the dry contact."""
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
self.async_write_ha_state()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off (open) the dry contact."""
if await self.entity_description.turn_off_fn(self.envoy, self.relay_id):
@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the storage settings switch."""
await self.entity_description.turn_on_fn(self.envoy)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the storage switch."""
await self.entity_description.turn_off_fn(self.envoy)

View File

@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch
from pyenphase.exceptions import EnvoyError
import pytest
from syrupy.assertion import SnapshotAssertion
@ -16,6 +17,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@ -112,6 +114,46 @@ async def test_switch_grid_operation(
mock_envoy.go_off_grid.reset_mock()
@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True)
async def test_switch_grid_operation_with_error(
hass: HomeAssistant,
mock_envoy: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Test switch platform operation for grid switches when error occurs."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, config_entry)
sn = mock_envoy.data.enpower.serial_number
test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled"
mock_envoy.go_off_grid.side_effect = EnvoyError("Test")
mock_envoy.go_on_grid.side_effect = EnvoyError("Test")
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_off for {test_entity}, host",
):
# test grid status switch operation
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_on for {test_entity}, host",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)
@pytest.mark.parametrize(
("mock_envoy", "use_serial"),
[
@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation(
mock_envoy.disable_charge_from_grid.reset_mock()
@pytest.mark.parametrize(
("mock_envoy", "use_serial"),
[
("envoy_metered_batt_relay", "enpower_654321"),
("envoy_eu_batt", "envoy_1234"),
],
indirect=["mock_envoy"],
)
async def test_switch_charge_from_grid_operation_with_error(
hass: HomeAssistant,
mock_envoy: AsyncMock,
config_entry: MockConfigEntry,
use_serial: str,
) -> None:
"""Test switch platform operation for charge from grid switches."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, config_entry)
test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid"
mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test")
mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test")
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_off for {test_entity}, host",
):
# test grid status switch operation
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_on for {test_entity}, host",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)
@pytest.mark.parametrize(
("mock_envoy", "entity_states"),
[
@ -232,3 +321,51 @@ async def test_switch_relay_operation(
assert mock_envoy.close_dry_contact.await_count == close_count
mock_envoy.open_dry_contact.reset_mock()
mock_envoy.close_dry_contact.reset_mock()
@pytest.mark.parametrize(
("mock_envoy", "relay"),
[("envoy_metered_batt_relay", "NC1")],
indirect=["mock_envoy"],
)
async def test_switch_relay_operation_with_error(
hass: HomeAssistant,
mock_envoy: AsyncMock,
config_entry: MockConfigEntry,
relay: str,
) -> None:
"""Test enphase_envoy switch relay entities operation."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, config_entry)
entity_base = f"{Platform.SWITCH}."
assert (dry_contact := mock_envoy.data.dry_contact_settings[relay])
assert (name := dry_contact.load_name.lower().replace(" ", "_"))
test_entity = f"{entity_base}{name}"
mock_envoy.close_dry_contact.side_effect = EnvoyError("Test")
mock_envoy.open_dry_contact.side_effect = EnvoyError("Test")
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_off for {test_entity}, host",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)
with pytest.raises(
HomeAssistantError,
match=f"Failed to execute async_turn_on for {test_entity}, host",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: test_entity},
blocking=True,
)