mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 14:17:45 +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
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user