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
from datetime import timedelta
import logging
from typing import Any
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.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@ -19,6 +24,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
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.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -115,6 +121,8 @@ async def async_setup_entry(
await host.stop()
raise ConfigEntryAuthFailed(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:
host.credential_errors = 0
raise UpdateFailed(str(err)) from err
@ -192,6 +200,23 @@ async def async_setup_entry(
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)
config_entry.async_on_unload(
@ -216,6 +241,10 @@ async def async_unload_entry(
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)

View File

@ -69,7 +69,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
super().__init__(coordinator)
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"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
@ -90,7 +92,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
@property
def available(self) -> bool:
"""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
def _push_callback(self) -> None:
@ -110,8 +116,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id
if cmd_key is not None:
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)
# Privacy mode
self.register_callback(f"{self._attr_unique_id}_623", 623)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
@ -119,8 +127,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
cmd_id = self.entity_description.cmd_id
if cmd_key is not None:
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)
# Privacy mode
self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623")
await super().async_will_remove_from_hass()

View File

@ -95,6 +95,7 @@ class ReolinkHost:
self.firmware_ch_list: list[int | None] = []
self.starting: bool = True
self.privacy_mode: bool | None = None
self.credential_errors: int = 0
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._fast_poll_error: bool = False
self._long_poll_task: asyncio.Task | None = None
self._lost_subscription_start: bool = False
self._lost_subscription: bool = False
self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None
@callback
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.privacy_mode = self._api.baichuan.privacy_mode()
ch_list: list[int | None] = [None]
if self._api.is_nvr:
ch_list.extend(self._api.channels)
@ -299,7 +304,7 @@ class ReolinkHost:
)
# 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(
"Camera model %s does not support ONVIF push, using ONVIF long polling instead",
self._api.model,
@ -416,6 +421,11 @@ class ReolinkHost:
wake = True
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)
async def disconnect(self) -> None:
@ -459,8 +469,8 @@ class ReolinkHost:
if initial:
raise
# make sure the long_poll_task is always created to try again later
if not self._lost_subscription:
self._lost_subscription = True
if not self._lost_subscription_start:
self._lost_subscription_start = True
_LOGGER.error(
"Reolink %s event long polling subscription lost: %s",
self._api.nvr_name,
@ -468,15 +478,15 @@ class ReolinkHost:
)
except ReolinkError as err:
# make sure the long_poll_task is always created to try again later
if not self._lost_subscription:
self._lost_subscription = True
if not self._lost_subscription_start:
self._lost_subscription_start = True
_LOGGER.error(
"Reolink %s event long polling subscription lost: %s",
self._api.nvr_name,
err,
)
else:
self._lost_subscription = False
self._lost_subscription_start = False
self._long_poll_task = asyncio.create_task(self._async_long_polling())
async def _async_stop_long_polling(self) -> None:
@ -543,6 +553,9 @@ class ReolinkHost:
self.unregister_webhook()
await self._api.unsubscribe()
if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to subscribe
try:
if self._onvif_push_supported and not self._api.baichuan.events_active:
await self._renew(SubType.push)
@ -666,7 +679,9 @@ class ReolinkHost:
try:
channels = await self._api.pull_point_request()
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)
await self._api.unsubscribe(sub_type=SubType.long_poll)
self._long_poll_error = True

View File

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

View File

@ -1,13 +1,18 @@
"""Test the Reolink init."""
import asyncio
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
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 (
DEVICE_UPDATE_INTERVAL,
@ -16,7 +21,13 @@ from homeassistant.components.reolink import (
)
from homeassistant.components.reolink.const import DOMAIN
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_config import async_process_ha_core_config
from homeassistant.helpers import (
@ -749,3 +760,102 @@ async def test_port_changed(
await hass.async_block_till_done()
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