diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 67356fcf862..5c2fa1b7a7e 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -31,6 +31,7 @@ from .entity import ( T, async_all_device_entities, ) +from .utils import async_ufp_instance_command _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -159,6 +160,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): entity_description: ProtectButtonEntityDescription + @async_ufp_instance_command async def async_press(self) -> None: """Press the button.""" if self.entity_description.ufp_press is not None: diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 8a35c6d6aa1..e0b0cb7205f 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -29,7 +29,7 @@ from .const import ( ) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity -from .utils import get_camera_base_name +from .utils import async_ufp_instance_command, get_camera_base_name _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -260,10 +260,12 @@ class ProtectCamera(ProtectDeviceEntity, Camera): """Return the Stream Source.""" return self._stream_source + @async_ufp_instance_command async def async_enable_motion_detection(self) -> None: """Call the job and enable motion detection.""" await self.device.set_motion_detection(True) + @async_ufp_instance_command async def async_disable_motion_detection(self) -> None: """Call the job and disable motion detection.""" await self.device.set_motion_detection(False) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 109b822a059..d0472c7b390 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity +from .utils import async_ufp_instance_command _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -71,6 +72,7 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): updated_device.light_device_settings.led_level ) + @async_ufp_instance_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) @@ -100,6 +102,7 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): ), ) + @async_ufp_instance_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" _LOGGER.debug("Turning off light") diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 95a7e51fb02..6cda3d5bbd6 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity +from .utils import async_ufp_instance_command _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -85,12 +86,14 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): elif lock_status != LockStatusType.OPEN: self._attr_available = False + @async_ufp_instance_command async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" _LOGGER.debug("Unlocking %s", self.device.display_name) - return await self.device.open_lock() + await self.device.open_lock() + @async_ufp_instance_command async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" _LOGGER.debug("Locking %s", self.device.display_name) - return await self.device.close_lock() + await self.device.close_lock() diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 23de97bef16..26ee052f6dd 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -28,6 +28,7 @@ from .entity import ( T, async_all_device_entities, ) +from .utils import async_ufp_instance_command PARALLEL_UPDATES = 0 @@ -297,6 +298,7 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @async_ufp_instance_command async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index ad19a79086f..20d5a263fe7 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -41,7 +41,7 @@ from .entity import ( T, async_all_device_entities, ) -from .utils import async_get_light_motion_current +from .utils import async_get_light_motion_current, async_ufp_instance_command _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -397,6 +397,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): self._hass_to_unifi_options = {item["name"]: item["id"] for item in options} self._unifi_to_hass_options = {item["id"]: item["name"] for item in options} + @async_ufp_instance_command async def async_select_option(self, option: str) -> None: """Change the Select Entity Option.""" diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 70f1cbaed44..102bbbb3ddb 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -616,12 +616,18 @@ "api_key_required": { "message": "API key is required. Please reauthenticate this integration to provide an API key." }, + "command_error": { + "message": "Error communicating with UniFi Protect while sending command: {error}" + }, "device_not_found": { "message": "No device found for device id: {device_id}" }, "no_users_found": { "message": "No users found, please check Protect permissions" }, + "not_authorized": { + "message": "Not authorized to perform this action on the UniFi Protect controller" + }, "only_music_supported": { "message": "Only music media type is supported" }, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index a40c071cc8b..a5b399ef8c4 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -33,6 +33,7 @@ from .entity import ( T, async_all_device_entities, ) +from .utils import async_ufp_instance_command ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" @@ -438,10 +439,12 @@ class ProtectBaseSwitch(ProtectIsOnEntity): entity_description: ProtectSwitchEntityDescription + @async_ufp_instance_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.entity_description.ufp_set(self.device, True) + @async_ufp_instance_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.entity_description.ufp_set(self.device, False) @@ -500,12 +503,14 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): if self.entity_id: self._update_previous_attr() + @async_ufp_instance_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._previous_mic_level = self.device.mic_volume self._previous_record_mode = self.device.recording_settings.mode await self.device.set_privacy(True, 0, RecordingMode.NEVER) + @async_ufp_instance_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" extra_state = self.extra_state_attributes or {} diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 5f651861a74..473acf1a40c 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -26,6 +26,7 @@ from .entity import ( T, async_all_device_entities, ) +from .utils import async_ufp_instance_command PARALLEL_UPDATES = 0 @@ -100,6 +101,7 @@ class ProtectDeviceText(ProtectDeviceEntity, TextEntity): super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @async_ufp_instance_command async def async_set_value(self, value: str) -> None: """Change the value.""" await self.entity_description.ufp_set(self.device, value) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 9071a24eae6..4632099b581 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -2,11 +2,12 @@ from __future__ import annotations -from collections.abc import Generator, Iterable +from collections.abc import Callable, Coroutine, Generator, Iterable import contextlib +from functools import wraps from pathlib import Path import socket -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Concatenate from aiohttp import CookieJar from uiprotect import ProtectApiClient @@ -18,6 +19,7 @@ from uiprotect.data import ( LightModeType, ProtectAdoptableDeviceModel, ) +from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.const import ( CONF_HOST, @@ -27,6 +29,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.storage import STORAGE_DIR @@ -34,11 +37,13 @@ from .const import ( CONF_ALL_UPDATES, CONF_OVERRIDE_CHOST, DEVICES_FOR_SUBSCRIBE, + DOMAIN, ModelType, ) if TYPE_CHECKING: from .data import UFPConfigEntry + from .entity import BaseProtectEntity @callback @@ -138,3 +143,31 @@ def get_camera_base_name(channel: CameraChannel) -> str: camera_name = f"{channel.name} resolution channel" return camera_name + + +def async_ufp_instance_command[_EntityT: "BaseProtectEntity", **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate UniFi Protect entity instance commands to handle exceptions. + + A decorator that wraps the passed in function, catches Protect errors, + and re-raises them as HomeAssistantError with translations. + """ + + @wraps(func) + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except NotAuthorized as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_authorized", + ) from err + except ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"error": str(err)}, + ) from err + + return handler diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 0fe3bbc64d0..0e5efd8a182 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode +from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.switch import ( @@ -18,6 +19,7 @@ from homeassistant.components.unifiprotect.switch import ( ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .utils import ( @@ -461,3 +463,47 @@ async def test_switch_camera_privacy_already_on( ) doorbell.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) + + +async def test_switch_turn_on_client_error( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Test switch turn on with ClientError raises HomeAssistantError.""" + + await init_entry(hass, ufp, [light]) + + description = LIGHT_SWITCHES[1] + + light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) + light.set_status_light = AsyncMock(side_effect=ClientError("Test error")) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + +async def test_switch_turn_on_not_authorized( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Test switch turn on with NotAuthorized raises HomeAssistantError.""" + + await init_entry(hass, ufp, [light]) + + description = LIGHT_SWITCHES[1] + + light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) + light.set_status_light = AsyncMock(side_effect=NotAuthorized("Not authorized")) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + )