mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
Prevent errors when Reolink privacy mode is turned on (#136506)
This commit is contained in:
parent
3e0f6562c7
commit
17e12e6671
@ -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)
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user