diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 747e68e8a00650..09ab41939ed165 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -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 @@ -19,10 +24,11 @@ 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 -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_PRIVACY, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -61,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -86,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_PRIVACY) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_PRIVACY: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -115,6 +127,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 @@ -192,6 +206,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( @@ -216,6 +247,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) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 48be2fc8ca7bba..cebe42ac15d63a 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -11,6 +12,7 @@ ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) import voluptuous as vol @@ -35,7 +37,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_PRIVACY, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -101,6 +103,7 @@ def __init__(self) -> None: self._host: str | None = None self._username: str = "admin" self._password: str | None = None + self._user_input: dict[str, Any] | None = None @staticmethod @callback @@ -198,8 +201,22 @@ async def async_step_dhcp( self._host = discovery_info.ip return await self.async_step_user() - async def async_step_user( + async def async_step_privacy( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask permission to disable privacy mode.""" + if user_input is not None: + return await self.async_step_user(self._user_input, disable_privacy=True) + + assert self._user_input is not None + placeholders = {"host": self._user_input[CONF_HOST]} + return self.async_show_form( + step_id="privacy", + description_placeholders=placeholders, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None, disable_privacy: bool = False ) -> ConfigFlowResult: """Handle the initial step.""" errors = {} @@ -219,6 +236,10 @@ async def async_step_user( host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: + if disable_privacy: + await host.api.baichuan.set_privacy_mode(enable=False) + # give the camera some time to startup the HTTP API server + await asyncio.sleep(5) await host.async_init() except UserNotAdmin: errors[CONF_USERNAME] = "not_admin" @@ -227,6 +248,9 @@ async def async_step_user( except PasswordIncompatible: errors[CONF_PASSWORD] = "password_incompatible" placeholders["special_chars"] = ALLOWED_SPECIAL_CHARS + except LoginPrivacyModeError: + self._user_input = user_input + return await self.async_step_privacy() except CredentialsInvalidError: errors[CONF_PASSWORD] = "invalid_auth" except LoginFirmwareError: @@ -260,6 +284,7 @@ async def async_step_user( if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_PRIVACY] = host.api.supported(None, "privacy_mode") mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac417f0..68c13fcd22afff 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_PRIVACY = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index dc2366e8f569dc..63c95c25025cb2 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -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}" @@ -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: @@ -110,8 +116,10 @@ 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.""" @@ -119,8 +127,10 @@ async def async_will_remove_from_hass(self) -> 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() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 97d888c0323a3f..d4ddda970dc6c7 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -32,13 +32,14 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_PRIVACY, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .store import ReolinkStore DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +65,12 @@ def __init__( hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -95,6 +99,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 @@ -112,7 +117,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: @@ -147,6 +154,13 @@ async def async_init(self) -> None: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: ReolinkStore | None = None + if self._config_entry_id is not None: + store = ReolinkStore(self._hass, self._config_entry_id) + if self._config.get(CONF_PRIVACY): + data = await store.async_load() + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -158,6 +172,19 @@ async def async_init(self) -> None: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store is not None + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_store(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -299,7 +326,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, @@ -416,6 +443,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: @@ -459,8 +491,8 @@ 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, @@ -468,15 +500,15 @@ async def _async_start_long_polling(self, initial: bool = False) -> None: ) 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 +575,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) @@ -666,7 +701,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 diff --git a/homeassistant/components/reolink/store.py b/homeassistant/components/reolink/store.py new file mode 100644 index 00000000000000..06de8d4beb5b70 --- /dev/null +++ b/homeassistant/components/reolink/store.py @@ -0,0 +1,45 @@ +"""Local storage for the Reolink integration.""" + +import asyncio +import logging +from pathlib import Path + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class ReolinkStore: + """Local storage for Reolink.""" + + def __init__(self, hass: HomeAssistant, config_id: str) -> None: + """Initialize ReolinkStore.""" + self._hass = hass + self._path = Path(hass.config.path(f".storage/reolink.{config_id}.json")) + self._lock = asyncio.Lock() + + async def async_load(self) -> str: + """Load the data from disk.""" + async with self._lock: + return await self._hass.async_add_executor_job(self._load) + + def _load(self) -> str: + """Load the data from disk.""" + if not self._path.exists(): + _LOGGER.debug("Failed to load file %s: path does not exist", self._path) + return "{}" + + try: + return self._path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.debug("Failed to load file %s: %s", self._path, err) + return "{}" + + async def async_store(self, data: str) -> None: + """Persist the data to storage.""" + async with self._lock: + await self._hass.async_add_executor_job(self._store, data) + + def _store(self, data: str) -> None: + """Persist the data to storage.""" + self._path.write_text(data, encoding="utf-8") diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 412362fc4472f6..11c9c57ba0555c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -18,6 +18,10 @@ "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } + }, + "privacy": { + "title": "Disable Reolink privacy mode", + "description": "Privacy mode is enabled on Reolink device {host}. By pressing SUBMIT, the privacy mode will be disabled to retrieve the nessesary information from the Reolink device. You can abort the setup by pressing X and repeat the setup at a time in which privacy mode can be disabled. After this configuration, you are free to enable the privacy mode again using the privacy mode switch entity. During normal startup the privacy mode will not be disabled. Note however that all entities will be marked unavailable as long as the privacy mode is active." } }, "error": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 81865d98801306..f8012f91351c01 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -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 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index f851e13c91dd7f..7895923dd12dde 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -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.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