mirror of
https://github.com/home-assistant/core.git
synced 2025-12-19 18:38:58 +00:00
Add exception handling to UniFi Protect entity commands (#159292)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user