1
0
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:
Raphael Hehl
2025-12-19 18:10:32 +01:00
committed by GitHub
parent d0411b6613
commit 5bbd56b8e6
11 changed files with 111 additions and 6 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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"
},

View File

@@ -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 {}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)