From 97fcbed6e08e3a37eb8d852f695a9d5bdfca514d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:07:10 +0100 Subject: [PATCH] Add error handling to enphase_envoy switch platform action (#136837) * Add error handling to enphase_envoy switch platform action * Use decorators for exception handling --- .../components/enphase_envoy/entity.py | 37 ++++- .../components/enphase_envoy/strings.json | 3 + .../components/enphase_envoy/switch.py | 8 +- tests/components/enphase_envoy/test_switch.py | 137 ++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 491951625ee..04987d861d2 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -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 diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 589dc52f71d..e99c45c5c7a 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -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}" } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 7074f341cc8..8a3ca493562 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -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) diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -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, + )