Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reolink privacy mode #135947

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion homeassistant/components/reolink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
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
Expand All @@ -19,6 +24,7 @@
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

Expand Down Expand Up @@ -115,6 +121,8 @@ async def async_device_config_update() -> None:
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
Expand Down Expand Up @@ -192,6 +200,23 @@ async def async_check_firmware_update() -> None:

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(
Expand All @@ -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)


Expand Down
18 changes: 14 additions & 4 deletions homeassistant/components/reolink/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ def __init__(
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}"
Expand All @@ -90,7 +92,11 @@ def __init__(
@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:
Expand All @@ -110,17 +116,21 @@ async def async_added_to_hass(self) -> 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."""
cmd_key = self.entity_description.cmd_key
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()

Expand Down
29 changes: 22 additions & 7 deletions homeassistant/components/reolink/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def get_aiohttp_session() -> aiohttp.ClientSession:
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
Expand All @@ -112,7 +113,9 @@ def get_aiohttp_session() -> aiohttp.ClientSession:
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:
Expand Down Expand Up @@ -232,6 +235,8 @@ async def async_init(self) -> None:
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)
Expand Down Expand Up @@ -299,7 +304,7 @@ async def _async_check_tcp_push(self, *_: Any) -> None:
)

# 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,
Expand Down Expand Up @@ -416,6 +421,11 @@ async def update_states(self) -> None:
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:
Expand Down Expand Up @@ -459,24 +469,24 @@ async def _async_start_long_polling(self, initial: bool = False) -> None:
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,
err,
)
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:
Expand Down Expand Up @@ -543,6 +553,9 @@ async def renew(self) -> None:
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)
Expand Down Expand Up @@ -666,7 +679,9 @@ async def _async_long_polling(self, *_: Any) -> None:
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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/reolink/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.11.6"]
"requirements": ["reolink-aio==0.11.7"]
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/components/reolink/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
114 changes: 112 additions & 2 deletions tests/components/reolink/test_init.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,7 +21,13 @@
)
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 (
Expand Down Expand Up @@ -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