Add error handling to enphase_envoy switch platform action (#136837)
* Add error handling to enphase_envoy switch platform action * Use decorators for exception handlingpull/132676/head^2
parent
a8175b785f
commit
97fcbed6e0
|
@ -2,13 +2,22 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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.entity import EntityDescription
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
from .coordinator import EnphaseUpdateCoordinator
|
from .coordinator import EnphaseUpdateCoordinator
|
||||||
|
|
||||||
|
ACTIONERRORS = (EnvoyError, HTTPError)
|
||||||
|
|
||||||
|
|
||||||
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||||
"""Defines a base envoy entity."""
|
"""Defines a base envoy entity."""
|
||||||
|
@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||||
data = self.coordinator.envoy.data
|
data = self.coordinator.envoy.data
|
||||||
assert data is not None
|
assert data is not None
|
||||||
return data
|
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
|
||||||
|
|
|
@ -400,6 +400,9 @@
|
||||||
},
|
},
|
||||||
"envoy_error": {
|
"envoy_error": {
|
||||||
"message": "Error communicating with Envoy API on {host}: {args}"
|
"message": "Error communicating with Envoy API on {host}: {args}"
|
||||||
|
},
|
||||||
|
"action_error": {
|
||||||
|
"message": "Failed to execute {action} for {entity}, host: {host}: {args}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||||
from .entity import EnvoyBaseEntity
|
from .entity import EnvoyBaseEntity, exception_handler
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||||
assert enpower is not None
|
assert enpower is not None
|
||||||
return self.entity_description.value_fn(enpower)
|
return self.entity_description.value_fn(enpower)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on the Enpower switch."""
|
"""Turn on the Enpower switch."""
|
||||||
await self.entity_description.turn_on_fn(self.envoy)
|
await self.entity_description.turn_on_fn(self.envoy)
|
||||||
await self.coordinator.async_request_refresh()
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the Enpower switch."""
|
"""Turn off the Enpower switch."""
|
||||||
await self.entity_description.turn_off_fn(self.envoy)
|
await self.entity_description.turn_off_fn(self.envoy)
|
||||||
|
@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||||
assert relay is not None
|
assert relay is not None
|
||||||
return self.entity_description.value_fn(relay)
|
return self.entity_description.value_fn(relay)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on (close) the dry contact."""
|
"""Turn on (close) the dry contact."""
|
||||||
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
|
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off (open) the dry contact."""
|
"""Turn off (open) the dry contact."""
|
||||||
if await self.entity_description.turn_off_fn(self.envoy, self.relay_id):
|
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
|
assert self.data.tariff.storage_settings is not None
|
||||||
return self.entity_description.value_fn(self.data.tariff.storage_settings)
|
return self.entity_description.value_fn(self.data.tariff.storage_settings)
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on the storage settings switch."""
|
"""Turn on the storage settings switch."""
|
||||||
await self.entity_description.turn_on_fn(self.envoy)
|
await self.entity_description.turn_on_fn(self.envoy)
|
||||||
await self.coordinator.async_request_refresh()
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
@exception_handler
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the storage switch."""
|
"""Turn off the storage switch."""
|
||||||
await self.entity_description.turn_off_fn(self.envoy)
|
await self.entity_description.turn_off_fn(self.envoy)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from pyenphase.exceptions import EnvoyError
|
||||||
import pytest
|
import pytest
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
@ -16,6 +17,7 @@ from homeassistant.const import (
|
||||||
STATE_ON,
|
STATE_ON,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
|
@ -112,6 +114,46 @@ async def test_switch_grid_operation(
|
||||||
mock_envoy.go_off_grid.reset_mock()
|
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(
|
@pytest.mark.parametrize(
|
||||||
("mock_envoy", "use_serial"),
|
("mock_envoy", "use_serial"),
|
||||||
[
|
[
|
||||||
|
@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation(
|
||||||
mock_envoy.disable_charge_from_grid.reset_mock()
|
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(
|
@pytest.mark.parametrize(
|
||||||
("mock_envoy", "entity_states"),
|
("mock_envoy", "entity_states"),
|
||||||
[
|
[
|
||||||
|
@ -232,3 +321,51 @@ async def test_switch_relay_operation(
|
||||||
assert mock_envoy.close_dry_contact.await_count == close_count
|
assert mock_envoy.close_dry_contact.await_count == close_count
|
||||||
mock_envoy.open_dry_contact.reset_mock()
|
mock_envoy.open_dry_contact.reset_mock()
|
||||||
mock_envoy.close_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,
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue