diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 0ea9a40b6b56..bb6e481b908b 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -3,7 +3,6 @@ from typing import override from pyimouapi.const import PARAM_RESTART_DEVICE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.button import ( @@ -12,12 +11,12 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, PTZ_MOVE_DURATION_MS, imou_device_identifier +from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 1 # Button types not yet exported by pyimouapi (keep module-local). @@ -100,18 +99,12 @@ class ImouButton(ImouEntity, ButtonEntity): entity_description: ButtonEntityDescription @override + @async_wrap_imou_command("press_button_failed") async def async_press(self) -> None: """Handle button press.""" duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0 - try: - await self.coordinator.device_manager.async_press_button( - self.device, - self._entity_type, - duration, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="press_button_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_press_button( + self.device, + self._entity_type, + duration, + ) diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index cbdd8debaa3e..f2d21ffbf7cb 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.camera import ( @@ -13,12 +12,12 @@ from homeassistant.components.camera import ( CameraEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, PARAM_HEADER_DETECT, imou_device_identifier +from .const import PARAM_HEADER_DETECT, imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -89,37 +88,25 @@ class ImouCamera(ImouEntity, Camera): super().__init__(coordinator, description, device) @override + @async_wrap_imou_command("get_stream_failed") async def stream_source(self) -> str | None: """Return the live stream URL from the Imou cloud.""" - try: - return await self.coordinator.device_manager.async_get_device_stream( - self.device, - self.entity_description.resolution, - PYIMOUAPI_LIVE_PROTOCOL, - ) - except ImouException as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="get_stream_failed", - translation_placeholders={"error": err.message}, - ) from err + return await self.coordinator.device_manager.async_get_device_stream( + self.device, + self.entity_description.resolution, + PYIMOUAPI_LIVE_PROTOCOL, + ) @override + @async_wrap_imou_command("get_image_failed") async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return bytes of camera image.""" - try: - return await self.coordinator.device_manager.async_get_device_image( - self.device, - PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, - ) - except ImouException as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="get_image_failed", - translation_placeholders={"error": err.message}, - ) from err + return await self.coordinator.device_manager.async_get_device_image( + self.device, + PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, + ) @property @override diff --git a/homeassistant/components/imou/helpers.py b/homeassistant/components/imou/helpers.py new file mode 100644 index 000000000000..7d22b8969bf5 --- /dev/null +++ b/homeassistant/components/imou/helpers.py @@ -0,0 +1,44 @@ +"""Helpers for Imou.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import ImouEntity + + +def async_wrap_imou_command[_T: ImouEntity, **_P, _R]( + error_key: str, +) -> Callable[ + [Callable[Concatenate[_T, _P], Awaitable[_R]]], + Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]], +]: + """Wrap an Imou command and start reauthentication when credentials are rejected.""" + + def decorator( + func: Callable[Concatenate[_T, _P], Awaitable[_R]], + ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except InvalidAppIdOrSecretException as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ImouException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) from err + + return wrapper + + return decorator diff --git a/homeassistant/components/imou/select.py b/homeassistant/components/imou/select.py index e4f74073fc0c..a4be6634e6c1 100644 --- a/homeassistant/components/imou/select.py +++ b/homeassistant/components/imou/select.py @@ -8,18 +8,17 @@ from pyimouapi.const import ( PARAM_NIGHT_VISION_MODE, PARAM_OPTIONS, ) -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, imou_device_identifier +from .const import imou_device_identifier from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -87,18 +86,12 @@ class ImouSelect(ImouEntity, SelectEntity): return self.device.selects[self._entity_type][PARAM_CURRENT_OPTION] @override + @async_wrap_imou_command("select_option_failed") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - try: - await self.coordinator.device_manager.async_select_option( - self.device, - self._entity_type, - option, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_select_option( + self.device, + self._entity_type, + option, + ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index c9834ce97069..e5872d8ed7d4 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -138,22 +138,22 @@ }, "exceptions": { "get_image_failed": { - "message": "Could not get a snapshot from Imou: {error}" + "message": "Could not get a snapshot from Imou" }, "get_stream_failed": { - "message": "Could not get the live stream URL from Imou: {error}" + "message": "Could not get the live stream URL from Imou" }, "invalid_auth": { "message": "Imou rejected the App ID and App secret" }, "press_button_failed": { - "message": "Imou rejected the button press: {error}" + "message": "Imou rejected the button press" }, "select_option_failed": { - "message": "Imou rejected the new option: {error}" + "message": "Imou rejected the new option" }, "switch_operation_failed": { - "message": "Imou rejected the switch change: {error}" + "message": "Imou rejected the switch change" } }, "selector": { diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index d60db6a676c7..91cb78fc35f5 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -3,7 +3,6 @@ from typing import Any, override from pyimouapi.const import PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice from homeassistant.components.switch import ( @@ -12,11 +11,9 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, PARAM_AB_ALARM_SOUND, PARAM_AUDIO_ENCODE_CONTROL, PARAM_CLOSE_CAMERA, @@ -28,6 +25,7 @@ from .const import ( ) from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator from .entity import ImouEntity +from .helpers import async_wrap_imou_command PARALLEL_UPDATES = 0 @@ -122,18 +120,12 @@ class ImouSwitch(ImouEntity, SwitchEntity): """Turn the switch off.""" await self._async_switch_operation(False) + @async_wrap_imou_command("switch_operation_failed") async def _async_switch_operation(self, enable: bool) -> None: """Call the vendor library to change switch state.""" - try: - await self.coordinator.device_manager.async_switch_operation( - self.device, - self._entity_type, - enable, - ) - except ImouException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_operation_failed", - translation_placeholders={"error": e.message}, - ) from e + await self.coordinator.device_manager.async_switch_operation( + self.device, + self._entity_type, + enable, + ) await self.coordinator.async_request_refresh() diff --git a/tests/components/imou/test_button.py b/tests/components/imou/test_button.py index 142016d36418..f1586d917654 100644 --- a/tests/components/imou/test_button.py +++ b/tests/components/imou/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_STATE, PARAM_STATUS -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +13,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.components.imou.button import PARAM_MUTE, PARAM_PTZ_UP from homeassistant.components.imou.const import PTZ_MOVE_DURATION_MS from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -130,8 +131,30 @@ async def test_press_button_service_propagates_api_error( entity_id = hass.states.async_all("button")[0].entity_id + with pytest.raises(HomeAssistantError, match="Imou rejected the button press"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_press_button_invalid_auth_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while pressing a button start reauthentication.""" + mock_imou_ha_device_manager.async_press_button.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = hass.states.async_all("button")[0].entity_id + with pytest.raises( - HomeAssistantError, match="Imou rejected the button press: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( BUTTON_DOMAIN, @@ -139,6 +162,10 @@ async def test_press_button_service_propagates_api_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize( diff --git a/tests/components/imou/test_camera.py b/tests/components/imou/test_camera.py index f5bb225bf3bd..94a53637e8fd 100644 --- a/tests/components/imou/test_camera.py +++ b/tests/components/imou/test_camera.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +16,7 @@ from homeassistant.components.imou.camera import ( ) from homeassistant.components.imou.const import PARAM_HEADER_DETECT from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -299,11 +300,48 @@ async def test_camera_stream_source_propagates_api_error( entity_id = _camera_entity_id(entity_registry, mock_config_entry) with pytest.raises( HomeAssistantError, - match="Could not get the live stream URL from Imou: stream failure", + match="Could not get the live stream URL from Imou", ): await async_get_stream_source(hass, entity_id) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_stream_source_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_imou_ha_device_manager: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Rejected credentials while fetching a stream start reauthentication.""" + mock_imou_ha_device_manager.async_get_device_stream.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + with pytest.raises( + HomeAssistantError, match="Imou rejected the App ID and App secret" + ): + await async_get_stream_source(hass, entity_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + @pytest.mark.parametrize( "imou_mock_devices", [ @@ -333,11 +371,48 @@ async def test_camera_image_propagates_api_error( entity_id = _camera_entity_id(entity_registry, mock_config_entry) with pytest.raises( HomeAssistantError, - match="Could not get a snapshot from Imou: image failure", + match="Could not get a snapshot from Imou", ): await async_get_image(hass, entity_id) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_image_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_imou_ha_device_manager: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Rejected credentials while fetching a snapshot start reauthentication.""" + mock_imou_ha_device_manager.async_get_device_image.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + with pytest.raises( + HomeAssistantError, match="Imou rejected the App ID and App secret" + ): + await async_get_image(hass, entity_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + @pytest.mark.parametrize( "imou_mock_devices", [ diff --git a/tests/components/imou/test_select.py b/tests/components/imou/test_select.py index c56e61f2ef4a..25cc64c27567 100644 --- a/tests/components/imou/test_select.py +++ b/tests/components/imou/test_select.py @@ -11,13 +11,14 @@ from pyimouapi.const import ( PARAM_STATE, PARAM_STATUS, ) -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.imou.coordinator import SCAN_INTERVAL from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, @@ -145,8 +146,39 @@ async def test_select_option_propagates_api_error( if entry.unique_id == "d1$device_volume" ) + with pytest.raises(HomeAssistantError, match="Imou rejected the new option"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: volume_entry.entity_id, ATTR_OPTION: "high"}, + blocking=True, + ) + + +@pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) +@pytest.mark.parametrize("imou_mock_devices", [select_mock_devices], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_select_option_invalid_auth_starts_reauth( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while changing a select start reauthentication.""" + mock_imou_ha_device_manager.async_select_option.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + volume_entry = next( + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.unique_id == "d1$device_volume" + ) + with pytest.raises( - HomeAssistantError, match="Imou rejected the new option: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( SELECT_DOMAIN, @@ -154,6 +186,10 @@ async def test_select_option_propagates_api_error( {ATTR_ENTITY_ID: volume_entry.entity_id, ATTR_OPTION: "high"}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) diff --git a/tests/components/imou/test_switch.py b/tests/components/imou/test_switch.py index 0d10e6c80664..04f2a7f4a9d2 100644 --- a/tests/components/imou/test_switch.py +++ b/tests/components/imou/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_MOTION_DETECT, PARAM_STATE, PARAM_STATUS -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest from syrupy.assertion import SnapshotAssertion @@ -12,6 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.imou.const import PARAM_HEADER_DETECT from homeassistant.components.imou.coordinator import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -173,8 +174,31 @@ async def test_turn_on_service_propagates_api_error( entity_id = hass.states.async_all("switch")[0].entity_id + with pytest.raises(HomeAssistantError, match="Imou rejected the switch change"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize("imou_mock_devices", [SWITCH_MOCK_DEVICES], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_invalid_auth_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Rejected credentials while toggling a switch start reauthentication.""" + mock_imou_ha_device_manager.async_switch_operation.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + entity_id = hass.states.async_all("switch")[0].entity_id + with pytest.raises( - HomeAssistantError, match="Imou rejected the switch change: cloud failure" + HomeAssistantError, match="Imou rejected the App ID and App secret" ): await hass.services.async_call( SWITCH_DOMAIN, @@ -182,6 +206,10 @@ async def test_turn_on_service_propagates_api_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize(