From a2a8a8f1192b59a700dfb1e98a5a720f059d7d10 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:06:25 +1300 Subject: [PATCH] ESPHome: Catch and re-raise client library errors as HomeAssistantErrors (#113026) --- .../components/esphome/alarm_control_panel.py | 14 +++++- homeassistant/components/esphome/button.py | 7 ++- homeassistant/components/esphome/climate.py | 13 +++++- homeassistant/components/esphome/cover.py | 14 +++++- homeassistant/components/esphome/entity.py | 27 +++++++++++- homeassistant/components/esphome/fan.py | 12 ++++- homeassistant/components/esphome/light.py | 9 +++- homeassistant/components/esphome/lock.py | 10 ++++- .../components/esphome/media_player.py | 13 +++++- homeassistant/components/esphome/number.py | 8 +++- homeassistant/components/esphome/select.py | 2 + homeassistant/components/esphome/switch.py | 9 +++- homeassistant/components/esphome/text.py | 8 +++- tests/components/esphome/test_number.py | 44 ++++++++++++++++++- 14 files changed, 176 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index c430934019c..0c483119b22 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -32,7 +32,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ @@ -114,42 +119,49 @@ class EsphomeAlarmControlPanel( """Return the state of the device.""" return _ESPHOME_ACP_STATE_TO_HASS_STATE.from_esphome(self._state.state) + @convert_api_error_ha_error async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.DISARM, code ) + @convert_api_error_ha_error async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_HOME, code ) + @convert_api_error_ha_error async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_AWAY, code ) + @convert_api_error_ha_error async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_NIGHT, code ) + @convert_api_error_ha_error async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code ) + @convert_api_error_ha_error async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_VACATION, code ) + @convert_api_error_ha_error async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self._client.alarm_control_panel_command( diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 406d86e9dc5..a825bb9b9b4 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -10,7 +10,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import EsphomeEntity, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + platform_async_setup_entry, +) async def async_setup_entry( @@ -53,6 +57,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): self._on_entry_data_changed() self.async_write_ha_state() + @convert_api_error_ha_error async def async_press(self) -> None: """Press the button.""" self._client.button_command(self._key) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 5337f9cf933..4225f60af0c 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -56,7 +56,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" @@ -274,6 +279,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the humidity we try to reach.""" return round(self._state.target_humidity) + @convert_api_error_ha_error async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -289,16 +295,19 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] self._client.climate_command(**data) + @convert_api_error_ha_error async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" self._client.climate_command(key=self._key, target_humidity=humidity) + @convert_api_error_ha_error async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" self._client.climate_command( key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) ) + @convert_api_error_ha_error async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" kwargs: dict[str, Any] = {"key": self._key} @@ -308,6 +317,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["preset"] = _PRESETS.from_hass(preset_mode) self._client.climate_command(**kwargs) + @convert_api_error_ha_error async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" kwargs: dict[str, Any] = {"key": self._key} @@ -317,6 +327,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) self._client.climate_command(**kwargs) + @convert_api_error_ha_error async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" self._client.climate_command( diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 3a1767d50f0..0b845c255a3 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -18,7 +18,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -95,30 +100,37 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): return None return round(self._state.tilt * 100.0) + @convert_api_error_ha_error async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._client.cover_command(key=self._key, position=1.0) + @convert_api_error_ha_error async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" self._client.cover_command(key=self._key, position=0.0) + @convert_api_error_ha_error async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._client.cover_command(key=self._key, stop=True) + @convert_api_error_ha_error async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100) + @convert_api_error_ha_error async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" self._client.cover_command(key=self._key, tilt=1.0) + @convert_api_error_ha_error async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" self._client.cover_command(key=self._key, tilt=0.0) + @convert_api_error_ha_error async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index b4cc54b0bb7..c069f93276b 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Coroutine import functools import math -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar, cast from aioesphomeapi import ( + APIConnectionError, EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, @@ -18,6 +19,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -32,6 +34,7 @@ from .entry_data import RuntimeEntryData from .enum_mapper import EsphomeEnumMapper _R = TypeVar("_R") +_P = ParamSpec("_P") _InfoT = TypeVar("_InfoT", bound=EntityInfo) _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) @@ -140,6 +143,26 @@ def esphome_state_property( return _wrapper +def convert_api_error_ha_error( + func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate ESPHome command calls that send commands/make changes to the device. + + A decorator that wraps the passed in function, catches APIConnectionError errors, + and raises a HomeAssistant error instead. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + return await func(self, *args, **kwargs) + except APIConnectionError as error: + raise HomeAssistantError( + f"Error communicating with device: {error}" + ) from error + + return handler + + ICON_SCHEMA = vol.Schema(cv.icon) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 25c81fcb8a8..082de3f7b7d 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -23,7 +23,12 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] @@ -60,6 +65,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Set the speed percentage of the fan.""" await self._async_set_percentage(percentage) + @convert_api_error_ha_error async def _async_set_percentage(self, percentage: int | None) -> None: if percentage == 0: await self.async_turn_off() @@ -89,20 +95,24 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Turn on the fan.""" await self._async_set_percentage(percentage) + @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" self._client.fan_command(key=self._key, state=False) + @convert_api_error_ha_error async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" self._client.fan_command(key=self._key, oscillating=oscillating) + @convert_api_error_ha_error async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" self._client.fan_command( key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + @convert_api_error_ha_error async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" self._client.fan_command(key=self._key, preset_mode=preset_mode) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index da6d4c7b1fc..bbb4021d58f 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -33,7 +33,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -173,6 +178,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return true if the light is on.""" return self._state.state + @convert_api_error_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._key, "state": True} @@ -288,6 +294,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._client.light_command(**data) + @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" data: dict[str, Any] = {"key": self._key, "state": False} diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 586a8e7af22..98efdece92e 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -12,7 +12,12 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -70,15 +75,18 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): """Return true if the lock is jammed (incomplete locking).""" return self._state.state == LockState.JAMMED + @convert_api_error_ha_error async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" self._client.lock_command(self._key, LockCommand.LOCK) + @convert_api_error_ha_error async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE, None) self._client.lock_command(self._key, LockCommand.UNLOCK, code) + @convert_api_error_ha_error async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" self._client.lock_command(self._key, LockCommand.OPEN) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 60ccc08cad4..c2bfdc5850d 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -26,7 +26,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -95,6 +100,7 @@ class EsphomeMediaPlayer( """Volume level of the media player (0..1).""" return self._state.volume + @convert_api_error_ha_error async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -124,22 +130,27 @@ class EsphomeMediaPlayer( content_filter=lambda item: item.media_content_type.startswith("audio/"), ) + @convert_api_error_ha_error async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._client.media_player_command(self._key, volume=volume) + @convert_api_error_ha_error async def async_media_pause(self) -> None: """Send pause command.""" self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE) + @convert_api_error_ha_error async def async_media_play(self) -> None: """Send play command.""" self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY) + @convert_api_error_ha_error async def async_media_stop(self) -> None: """Send stop command.""" self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP) + @convert_api_error_ha_error async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" self._client.media_player_command( diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index c9511ffe5bc..01744dd9998 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -17,7 +17,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -78,6 +83,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): return None return state.state + @convert_api_error_ha_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" self._client.number_command(self._key, value) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 8f6fa4af6f0..07a9d70e558 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -18,6 +18,7 @@ from .domain_data import DomainData from .entity import ( EsphomeAssistEntity, EsphomeEntity, + convert_api_error_ha_error, esphome_state_property, platform_async_setup_entry, ) @@ -66,6 +67,7 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): state = self._state return None if state.missing_state else state.state + @convert_api_error_ha_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" self._client.select_command(self._key, option) diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index f42e215eeaa..6fa73058bd2 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -12,7 +12,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -48,10 +53,12 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """Return true if the switch is on.""" return self._state.state + @convert_api_error_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" self._client.switch_command(self._key, True) + @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" self._client.switch_command(self._key, False) diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index 9cd7cb4c008..7d455e9ec21 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -9,7 +9,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -59,6 +64,7 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): return None return state.state + @convert_api_error_ha_error async def async_set_value(self, value: str) -> None: """Update the current value.""" self._client.text_command(self._key, value) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index dc90d1c1098..557425052f3 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -1,14 +1,16 @@ """Test ESPHome numbers.""" import math -from unittest.mock import call +from unittest.mock import Mock, call from aioesphomeapi import ( APIClient, + APIConnectionError, NumberInfo, NumberMode as ESPHomeNumberMode, NumberState, ) +import pytest from homeassistant.components.number import ( ATTR_VALUE, @@ -17,6 +19,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError async def test_generic_number_entity( @@ -122,3 +125,42 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( assert state is not None assert state.state == "42" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + +async def test_generic_number_entity_set_when_disconnected( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic number entity.""" + entity_info = [ + NumberInfo( + object_id="mynumber", + key=1, + name="my number", + unique_id="my_number", + max_value=100, + min_value=0, + step=1, + unit_of_measurement="%", + ) + ] + states = [NumberState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + + mock_client.number_command = Mock(side_effect=APIConnectionError("Not connected")) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 20}, + blocking=True, + ) + mock_client.number_command.reset_mock()