Prevent errors when Reolink privacy mode is turned on (#136506)

This commit is contained in:
starkillerOG 2025-01-26 22:44:15 +01:00 committed by GitHub
parent 3e0f6562c7
commit 17e12e6671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 179 additions and 14 deletions

View File

@ -5,9 +5,14 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from reolink_aio.exceptions import (
CredentialsInvalidError,
LoginPrivacyModeError,
ReolinkError,
)
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@ -19,6 +24,7 @@ from homeassistant.helpers import (
entity_registry as er, entity_registry as er,
) )
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -115,6 +121,8 @@ async def async_setup_entry(
await host.stop() await host.stop()
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err
except LoginPrivacyModeError:
pass # HTTP API is shutdown when privacy mode is active
except ReolinkError as err: except ReolinkError as err:
host.credential_errors = 0 host.credential_errors = 0
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err
@ -192,6 +200,23 @@ async def async_setup_entry(
hass.http.register_view(PlaybackProxyView(hass)) hass.http.register_view(PlaybackProxyView(hass))
async def refresh(*args: Any) -> None:
"""Request refresh of coordinator."""
await device_coordinator.async_request_refresh()
host.cancel_refresh_privacy_mode = None
def async_privacy_mode_change() -> None:
"""Request update when privacy mode is turned off."""
if host.privacy_mode and not host.api.baichuan.privacy_mode():
# The privacy mode just turned off, give the API 2 seconds to start
if host.cancel_refresh_privacy_mode is None:
host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh)
host.privacy_mode = host.api.baichuan.privacy_mode()
host.api.baichuan.register_callback(
"privacy_mode_change", async_privacy_mode_change, 623
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload( config_entry.async_on_unload(
@ -216,6 +241,10 @@ async def async_unload_entry(
await host.stop() await host.stop()
host.api.baichuan.unregister_callback("privacy_mode_change")
if host.cancel_refresh_privacy_mode is not None:
host.cancel_refresh_privacy_mode()
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@ -69,7 +69,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
super().__init__(coordinator) super().__init__(coordinator)
self._host = reolink_data.host self._host = reolink_data.host
self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" self._attr_unique_id: str = (
f"{self._host.unique_id}_{self.entity_description.key}"
)
http_s = "https" if self._host.api.use_https else "http" http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
@ -90,7 +92,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return self._host.api.session_active and super().available return (
self._host.api.session_active
and not self._host.api.baichuan.privacy_mode()
and super().available
)
@callback @callback
def _push_callback(self) -> None: def _push_callback(self) -> None:
@ -110,8 +116,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id cmd_id = self.entity_description.cmd_id
if cmd_key is not None: if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key) self._host.async_register_update_cmd(cmd_key)
if cmd_id is not None and self._attr_unique_id is not None: if cmd_id is not None:
self.register_callback(self._attr_unique_id, cmd_id) self.register_callback(self._attr_unique_id, cmd_id)
# Privacy mode
self.register_callback(f"{self._attr_unique_id}_623", 623)
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Entity removed.""" """Entity removed."""
@ -119,8 +127,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id cmd_id = self.entity_description.cmd_id
if cmd_key is not None: if cmd_key is not None:
self._host.async_unregister_update_cmd(cmd_key) self._host.async_unregister_update_cmd(cmd_key)
if cmd_id is not None and self._attr_unique_id is not None: if cmd_id is not None:
self._host.api.baichuan.unregister_callback(self._attr_unique_id) self._host.api.baichuan.unregister_callback(self._attr_unique_id)
# Privacy mode
self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623")
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()

View File

@ -95,6 +95,7 @@ class ReolinkHost:
self.firmware_ch_list: list[int | None] = [] self.firmware_ch_list: list[int | None] = []
self.starting: bool = True self.starting: bool = True
self.privacy_mode: bool | None = None
self.credential_errors: int = 0 self.credential_errors: int = 0
self.webhook_id: str | None = None self.webhook_id: str | None = None
@ -112,7 +113,9 @@ class ReolinkHost:
self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True)
self._fast_poll_error: bool = False self._fast_poll_error: bool = False
self._long_poll_task: asyncio.Task | None = None self._long_poll_task: asyncio.Task | None = None
self._lost_subscription_start: bool = False
self._lost_subscription: bool = False self._lost_subscription: bool = False
self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None
@callback @callback
def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None:
@ -232,6 +235,8 @@ class ReolinkHost:
self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push
) )
self.privacy_mode = self._api.baichuan.privacy_mode()
ch_list: list[int | None] = [None] ch_list: list[int | None] = [None]
if self._api.is_nvr: if self._api.is_nvr:
ch_list.extend(self._api.channels) ch_list.extend(self._api.channels)
@ -299,7 +304,7 @@ class ReolinkHost:
) )
# start long polling if ONVIF push failed immediately # start long polling if ONVIF push failed immediately
if not self._onvif_push_supported: if not self._onvif_push_supported and not self._api.baichuan.privacy_mode():
_LOGGER.debug( _LOGGER.debug(
"Camera model %s does not support ONVIF push, using ONVIF long polling instead", "Camera model %s does not support ONVIF push, using ONVIF long polling instead",
self._api.model, self._api.model,
@ -416,6 +421,11 @@ class ReolinkHost:
wake = True wake = True
self.last_wake = time() self.last_wake = time()
if self._api.baichuan.privacy_mode():
await self._api.baichuan.get_privacy_mode()
if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to check states
await self._api.get_states(cmd_list=self.update_cmd, wake=wake) await self._api.get_states(cmd_list=self.update_cmd, wake=wake)
async def disconnect(self) -> None: async def disconnect(self) -> None:
@ -459,8 +469,8 @@ class ReolinkHost:
if initial: if initial:
raise raise
# make sure the long_poll_task is always created to try again later # make sure the long_poll_task is always created to try again later
if not self._lost_subscription: if not self._lost_subscription_start:
self._lost_subscription = True self._lost_subscription_start = True
_LOGGER.error( _LOGGER.error(
"Reolink %s event long polling subscription lost: %s", "Reolink %s event long polling subscription lost: %s",
self._api.nvr_name, self._api.nvr_name,
@ -468,15 +478,15 @@ class ReolinkHost:
) )
except ReolinkError as err: except ReolinkError as err:
# make sure the long_poll_task is always created to try again later # make sure the long_poll_task is always created to try again later
if not self._lost_subscription: if not self._lost_subscription_start:
self._lost_subscription = True self._lost_subscription_start = True
_LOGGER.error( _LOGGER.error(
"Reolink %s event long polling subscription lost: %s", "Reolink %s event long polling subscription lost: %s",
self._api.nvr_name, self._api.nvr_name,
err, err,
) )
else: else:
self._lost_subscription = False self._lost_subscription_start = False
self._long_poll_task = asyncio.create_task(self._async_long_polling()) self._long_poll_task = asyncio.create_task(self._async_long_polling())
async def _async_stop_long_polling(self) -> None: async def _async_stop_long_polling(self) -> None:
@ -543,6 +553,9 @@ class ReolinkHost:
self.unregister_webhook() self.unregister_webhook()
await self._api.unsubscribe() await self._api.unsubscribe()
if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to subscribe
try: try:
if self._onvif_push_supported and not self._api.baichuan.events_active: if self._onvif_push_supported and not self._api.baichuan.events_active:
await self._renew(SubType.push) await self._renew(SubType.push)
@ -666,7 +679,9 @@ class ReolinkHost:
try: try:
channels = await self._api.pull_point_request() channels = await self._api.pull_point_request()
except ReolinkError as ex: except ReolinkError as ex:
if not self._long_poll_error: if not self._long_poll_error and self._api.subscribed(
SubType.long_poll
):
_LOGGER.error("Error while requesting ONVIF pull point: %s", ex) _LOGGER.error("Error while requesting ONVIF pull point: %s", ex)
await self._api.unsubscribe(sub_type=SubType.long_poll) await self._api.unsubscribe(sub_type=SubType.long_poll)
self._long_poll_error = True self._long_poll_error = True

View File

@ -126,6 +126,7 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.baichuan = create_autospec(Baichuan) host_mock.baichuan = create_autospec(Baichuan)
# Disable tcp push by default for tests # Disable tcp push by default for tests
host_mock.baichuan.events_active = False host_mock.baichuan.events_active = False
host_mock.baichuan.privacy_mode.return_value = False
host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error")
yield host_mock_class yield host_mock_class

View File

@ -1,13 +1,18 @@
"""Test the Reolink init.""" """Test the Reolink init."""
import asyncio import asyncio
from collections.abc import Callable
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from reolink_aio.api import Chime from reolink_aio.api import Chime
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from reolink_aio.exceptions import (
CredentialsInvalidError,
LoginPrivacyModeError,
ReolinkError,
)
from homeassistant.components.reolink import ( from homeassistant.components.reolink import (
DEVICE_UPDATE_INTERVAL, DEVICE_UPDATE_INTERVAL,
@ -16,7 +21,13 @@ from homeassistant.components.reolink import (
) )
from homeassistant.components.reolink.const import DOMAIN from homeassistant.components.reolink.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PORT, STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.const import (
CONF_PORT,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.core_config import async_process_ha_core_config from homeassistant.core_config import async_process_ha_core_config
from homeassistant.helpers import ( from homeassistant.helpers import (
@ -749,3 +760,102 @@ async def test_port_changed(
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.data[CONF_PORT] == 4567 assert config_entry.data[CONF_PORT] == 4567
async def test_privacy_mode_on(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test successful setup even when privacy mode is turned on."""
reolink_connect.baichuan.privacy_mode.return_value = True
reolink_connect.get_states = AsyncMock(
side_effect=LoginPrivacyModeError("Test error")
)
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
reolink_connect.baichuan.privacy_mode.return_value = False
async def test_LoginPrivacyModeError(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test normal update when get_states returns a LoginPrivacyModeError."""
reolink_connect.baichuan.privacy_mode.return_value = False
reolink_connect.get_states = AsyncMock(
side_effect=LoginPrivacyModeError("Test error")
)
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
reolink_connect.baichuan.check_subscribe_events.reset_mock()
assert reolink_connect.baichuan.check_subscribe_events.call_count == 0
freezer.tick(DEVICE_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1
async def test_privacy_mode_change_callback(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test privacy mode changed callback."""
class callback_mock_class:
callback_func = None
def register_callback(
self, callback_id: str, callback: Callable[[], None], *args, **key_args
) -> None:
if callback_id == "privacy_mode_change":
self.callback_func = callback
callback_mock = callback_mock_class()
reolink_connect.model = TEST_HOST_MODEL
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_connect.baichuan.register_callback = callback_mock.register_callback
reolink_connect.baichuan.privacy_mode.return_value = True
reolink_connect.audio_record.return_value = True
reolink_connect.get_states = AsyncMock()
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio"
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# simulate a TCP push callback signaling a privacy mode change
reolink_connect.baichuan.privacy_mode.return_value = False
assert callback_mock.callback_func is not None
callback_mock.callback_func()
# check that a coordinator update was scheduled.
reolink_connect.get_states.reset_mock()
assert reolink_connect.get_states.call_count == 0
freezer.tick(5)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert reolink_connect.get_states.call_count >= 1
assert hass.states.get(entity_id).state == STATE_ON