1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add action exception handling to Actron Air (#160579)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Kurt Chrisford
2026-02-13 07:46:53 +10:00
committed by GitHub
parent 34a445545c
commit 6bfaf6b188
9 changed files with 546 additions and 6 deletions

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -136,16 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -209,11 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -1,7 +1,12 @@
"""Base entity classes for Actron Air integration."""
from actron_neo_api import ActronAirZone
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from actron_neo_api import ActronAirAPIError, ActronAirZone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -9,6 +14,26 @@ from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)
except ActronAirAPIError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
return wrapper
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""

View File

@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt

View File

@@ -49,6 +49,9 @@
}
},
"exceptions": {
"api_error": {
"message": "Failed to communicate with Actron Air device: {error}"
},
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity
from .entity import ActronAirAcEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -105,10 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@handle_actron_api_errors
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@handle_actron_api_errors
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.actron_air.const import DOMAIN
from homeassistant.const import CONF_API_TOKEN
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
@@ -62,11 +65,14 @@ def mock_actron_api() -> Generator[AsyncMock]:
api.state_manager = MagicMock()
status = api.state_manager.get_status.return_value
status.master_info.live_temp_c = 22.0
status.master_info.live_humidity_pc = 50.0
status.ac_system.system_name = "Test System"
status.ac_system.serial_number = "123456"
status.ac_system.master_wc_model = "Test Model"
status.ac_system.master_wc_firmware_version = "1.0.0"
status.ac_system.set_system_mode = AsyncMock()
status.remote_zone_info = []
status.zones = {}
status.min_temp = 16
status.max_temp = 30
status.aircon_system.mode = "OFF"
@@ -75,18 +81,27 @@ def mock_actron_api() -> Generator[AsyncMock]:
status.room_temp = 25
status.is_on = False
# Mock user_aircon_settings for the switch platform
# Mock user_aircon_settings for the switch and climate platforms
settings = status.user_aircon_settings
settings.away_mode = False
settings.continuous_fan_enabled = False
settings.quiet_mode_enabled = False
settings.turbo_enabled = False
settings.turbo_supported = True
settings.is_on = False
settings.mode = "COOL"
settings.base_fan_mode = "LOW"
settings.temperature_setpoint_cool_c = 24.0
settings.set_away_mode = AsyncMock()
settings.set_continuous_mode = AsyncMock()
settings.set_quiet_mode = AsyncMock()
settings.set_turbo_mode = AsyncMock()
settings.set_temperature = AsyncMock()
settings.set_fan_mode = AsyncMock()
# Mock ac_system methods for climate platform
status.ac_system.set_system_mode = AsyncMock()
yield api
@@ -102,6 +117,26 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def mock_zone() -> MagicMock:
"""Return a mocked zone."""
zone = MagicMock()
zone.exists = True
zone.zone_id = 1
zone.zone_name = "Test Zone"
zone.title = "Living Room"
zone.live_temp_c = 22.0
zone.temperature_setpoint_cool_c = 24.0
zone.is_active = True
zone.hvac_mode = "COOL"
zone.humidity = 50.0
zone.min_temp = 16
zone.max_temp = 30
zone.set_temperature = AsyncMock()
zone.enable = AsyncMock()
return zone
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock async_setup_entry."""
@@ -109,3 +144,19 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.actron_air.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
async def init_integration_with_zone(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_actron_api: AsyncMock,
mock_zone: MagicMock,
) -> None:
"""Set up the Actron Air integration with zone for testing."""
status = mock_actron_api.state_manager.get_status.return_value
status.remote_zone_info = [mock_zone]
status.zones = {1: mock_zone}
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)

View File

@@ -0,0 +1,158 @@
# serializer version: 1
# name: test_climate_entities[climate.living_room-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30,
'min_temp': 16,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'actron_air',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 385>,
'translation_key': None,
'unique_id': '123456_zone_1',
'unit_of_measurement': None,
})
# ---
# name: test_climate_entities[climate.living_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 50.0,
'current_temperature': 22.0,
'friendly_name': 'Living Room',
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30,
'min_temp': 16,
'supported_features': <ClimateEntityFeature: 385>,
'temperature': 24.0,
}),
'context': <ANY>,
'entity_id': 'climate.living_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---
# name: test_climate_entities[climate.test_system-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'auto',
'low',
'medium',
'high',
]),
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30,
'min_temp': 16,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.test_system',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'actron_air',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 393>,
'translation_key': None,
'unique_id': '123456',
'unit_of_measurement': None,
})
# ---
# name: test_climate_entities[climate.test_system-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 50.0,
'current_temperature': 22.0,
'fan_mode': 'low',
'fan_modes': list([
'auto',
'low',
'medium',
'high',
]),
'friendly_name': 'Test System',
'hvac_modes': list([
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 30,
'min_temp': 16,
'supported_features': <ClimateEntityFeature: 393>,
'temperature': 24.0,
}),
'context': <ANY>,
'entity_id': 'climate.test_system',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,260 @@
"""Tests for the Actron Air climate platform."""
from unittest.mock import MagicMock, patch
from actron_neo_api import ActronAirAPIError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_climate_entities(
hass: HomeAssistant,
mock_actron_api: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
mock_zone: MagicMock,
) -> None:
"""Test climate entities."""
status = mock_actron_api.state_manager.get_status.return_value
status.remote_zone_info = [mock_zone]
status.zones = {1: mock_zone}
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_system_set_temperature(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting temperature for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_TEMPERATURE: 22.5},
blocking=True,
)
status.user_aircon_settings.set_temperature.assert_awaited_once_with(
temperature=22.5
)
async def test_system_set_temperature_api_error(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test API error when setting temperature for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
status.user_aircon_settings.set_temperature.side_effect = ActronAirAPIError(
"Test error"
)
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_TEMPERATURE: 22.5},
blocking=True,
)
async def test_system_set_fan_mode(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting fan mode for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_FAN_MODE: "low"},
blocking=True,
)
status.user_aircon_settings.set_fan_mode.assert_awaited_once_with("LOW")
async def test_system_set_fan_mode_api_error(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test API error when setting fan mode for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
status.user_aircon_settings.set_fan_mode.side_effect = ActronAirAPIError(
"Test error"
)
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_FAN_MODE: "high"},
blocking=True,
)
async def test_system_set_hvac_mode(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting HVAC mode for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_HVAC_MODE: HVACMode.COOL},
blocking=True,
)
status.ac_system.set_system_mode.assert_awaited_once_with("COOL")
async def test_system_set_hvac_mode_api_error(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test API error when setting HVAC mode for system climate entity."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
status.ac_system.set_system_mode.side_effect = ActronAirAPIError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.test_system", ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
async def test_zone_set_temperature(
hass: HomeAssistant,
init_integration_with_zone: None,
mock_zone: MagicMock,
) -> None:
"""Test setting temperature for zone climate entity."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_TEMPERATURE: 23.0},
blocking=True,
)
mock_zone.set_temperature.assert_awaited_once_with(temperature=23.0)
async def test_zone_set_temperature_api_error(
hass: HomeAssistant,
init_integration_with_zone: None,
mock_zone: MagicMock,
) -> None:
"""Test API error when setting temperature for zone climate entity."""
mock_zone.set_temperature.side_effect = ActronAirAPIError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_TEMPERATURE: 23.0},
blocking=True,
)
async def test_zone_set_hvac_mode_on(
hass: HomeAssistant,
init_integration_with_zone: None,
mock_zone: MagicMock,
) -> None:
"""Test setting HVAC mode to on for zone climate entity."""
mock_zone.is_active = False
mock_zone.hvac_mode = "OFF"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.COOL},
blocking=True,
)
mock_zone.enable.assert_awaited_once_with(True)
async def test_zone_set_hvac_mode_off(
hass: HomeAssistant,
init_integration_with_zone: None,
mock_zone: MagicMock,
) -> None:
"""Test setting HVAC mode to off for zone climate entity."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)
mock_zone.enable.assert_awaited_once_with(False)
async def test_zone_set_hvac_mode_api_error(
hass: HomeAssistant,
init_integration_with_zone: None,
mock_zone: MagicMock,
) -> None:
"""Test API error when setting HVAC mode for zone climate entity."""
mock_zone.enable.side_effect = ActronAirAPIError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)

View File

@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
from actron_neo_api import ActronAirAPIError
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -12,6 +13,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -88,3 +90,37 @@ async def test_turbo_mode_not_supported(
entity_id = "switch.test_system_turbo_mode"
assert not hass.states.get(entity_id)
assert not entity_registry.async_get(entity_id)
@pytest.mark.parametrize(
("entity_id", "method", "service"),
[
("switch.test_system_away_mode", "set_away_mode", SERVICE_TURN_ON),
("switch.test_system_continuous_fan", "set_continuous_mode", SERVICE_TURN_OFF),
("switch.test_system_quiet_mode", "set_quiet_mode", SERVICE_TURN_ON),
("switch.test_system_turbo_mode", "set_turbo_mode", SERVICE_TURN_OFF),
],
)
async def test_switch_api_error(
hass: HomeAssistant,
mock_actron_api: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
method: str,
service: str,
) -> None:
"""Test API error handling when toggling switches."""
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
status = mock_actron_api.state_manager.get_status.return_value
mock_method = getattr(status.user_aircon_settings, method)
mock_method.side_effect = ActronAirAPIError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: [entity_id]},
blocking=True,
)