Start Imou reauthentication when an entity action rejects the credentials (#181005)

This commit is contained in:
Imou-OpenPlatform
2026-09-02 10:43:06 +02:00
committed by GitHub
parent eeb462fdfa
commit 0a8ac8ab5a
10 changed files with 260 additions and 85 deletions
+8 -15
View File
@@ -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,
)
+13 -26
View File
@@ -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
+44
View File
@@ -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
+8 -15
View File
@@ -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()
+5 -5
View File
@@ -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": {
+7 -15
View File
@@ -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()
+29 -2
View File
@@ -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(
+78 -3
View File
@@ -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",
[
+38 -2
View File
@@ -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)
+30 -2
View File
@@ -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(