Ensure Reolink can start when privacy mode is enabled (#136514)

* Allow startup when privacy mode is enabled

* Add tests

* remove duplicate privacy_mode

* fix tests

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* Store in subfolder and cleanup when removed

* Add tests and fixes

* fix styling

* rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE

* use helper store

---------

Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
starkillerOG 2025-01-31 19:48:47 +01:00 committed by GitHub
parent df59b1d4fa
commit 92dd18a9be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 18 deletions

View File

@ -28,11 +28,11 @@ 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
from .const import CONF_USE_HTTPS, DOMAIN from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost from .host import ReolinkHost
from .services import async_setup_services from .services import async_setup_services
from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store
from .views import PlaybackProxyView from .views import PlaybackProxyView
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -67,7 +67,9 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: ReolinkConfigEntry hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> bool: ) -> bool:
"""Set up Reolink from a config entry.""" """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: try:
await host.async_init() await host.async_init()
@ -92,11 +94,14 @@ async def async_setup_entry(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) 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 ( if (
host.api.port != config_entry.data[CONF_PORT] host.api.port != config_entry.data[CONF_PORT]
or host.api.use_https != config_entry.data[CONF_USE_HTTPS] or host.api.use_https != config_entry.data[CONF_USE_HTTPS]
or host.api.supported(None, "privacy_mode")
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
): ):
if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning( _LOGGER.warning(
"HTTP(s) port of Reolink %s, changed from %s to %s", "HTTP(s) port of Reolink %s, changed from %s to %s",
host.api.nvr_name, host.api.nvr_name,
@ -107,6 +112,7 @@ async def async_setup_entry(
**config_entry.data, **config_entry.data,
CONF_PORT: host.api.port, CONF_PORT: host.api.port,
CONF_USE_HTTPS: host.api.use_https, CONF_USE_HTTPS: host.api.use_https,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
} }
hass.config_entries.async_update_entry(config_entry, data=data) hass.config_entries.async_update_entry(config_entry, data=data)
@ -248,6 +254,14 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def async_remove_entry(
hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> None:
"""Handle removal of an entry."""
store = get_store(hass, config_entry.entry_id)
await store.async_remove()
async def async_remove_config_entry_device( async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry
) -> bool: ) -> bool:

View File

@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_USE_HTTPS, DOMAIN from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import ( from .exceptions import (
PasswordIncompatible, PasswordIncompatible,
ReolinkException, ReolinkException,
@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors: if not errors:
user_input[CONF_PORT] = host.api.port user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https user_input[CONF_USE_HTTPS] = host.api.use_https
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode"
)
mac_address = format_mac(host.api.mac_address) mac_address = format_mac(host.api.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False) await self.async_set_unique_id(mac_address, raise_on_progress=False)

View File

@ -3,3 +3,4 @@
DOMAIN = "reolink" DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https" CONF_USE_HTTPS = "use_https"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"

View File

@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.storage import Store
from homeassistant.util.ssl import SSLCipherList from homeassistant.util.ssl import SSLCipherList
from .const import CONF_USE_HTTPS, DOMAIN from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import ( from .exceptions import (
PasswordIncompatible, PasswordIncompatible,
ReolinkSetupException, ReolinkSetupException,
ReolinkWebhookException, ReolinkWebhookException,
UserNotAdmin, UserNotAdmin,
) )
from .util import get_store
DEFAULT_TIMEOUT = 30 DEFAULT_TIMEOUT = 30
FIRST_TCP_PUSH_TIMEOUT = 10 FIRST_TCP_PUSH_TIMEOUT = 10
@ -64,9 +66,12 @@ class ReolinkHost:
hass: HomeAssistant, hass: HomeAssistant,
config: Mapping[str, Any], config: Mapping[str, Any],
options: Mapping[str, Any], options: Mapping[str, Any],
config_entry_id: str | None = None,
) -> None: ) -> None:
"""Initialize Reolink Host. Could be either NVR, or Camera.""" """Initialize Reolink Host. Could be either NVR, or Camera."""
self._hass: HomeAssistant = hass self._hass: HomeAssistant = hass
self._config_entry_id = config_entry_id
self._config = config
self._unique_id: str = "" self._unique_id: str = ""
def get_aiohttp_session() -> aiohttp.ClientSession: def get_aiohttp_session() -> aiohttp.ClientSession:
@ -150,6 +155,14 @@ class ReolinkHost:
f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}"
) )
store: Store[str] | None = None
if self._config_entry_id is not None:
store = get_store(self._hass, self._config_entry_id)
if self._config.get(CONF_SUPPORTS_PRIVACY_MODE):
data = await store.async_load()
if data:
self._api.set_raw_host_data(data)
await self._api.get_host_data() await self._api.get_host_data()
if self._api.mac_address is None: if self._api.mac_address is None:
@ -161,6 +174,19 @@ class ReolinkHost:
f"'{self._api.user_level}', only admin users can change camera settings" f"'{self._api.user_level}', only admin users can change camera settings"
) )
self.privacy_mode = self._api.baichuan.privacy_mode()
if (
store
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_save(data)
onvif_supported = self._api.supported(None, "ONVIF") onvif_supported = self._api.supported(None, "ONVIF")
self._onvif_push_supported = onvif_supported self._onvif_push_supported = onvif_supported
self._onvif_long_poll_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported
@ -235,8 +261,6 @@ 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)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import TYPE_CHECKING, Any
from reolink_aio.exceptions import ( from reolink_aio.exceptions import (
ApiError, ApiError,
@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
from .host import ReolinkHost
if TYPE_CHECKING:
from .host import ReolinkHost
STORAGE_VERSION = 1
type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData]
@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
return config_entry.runtime_data.host return config_entry.runtime_data.host
def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]:
"""Return the reolink store."""
return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json")
def get_device_uid_and_ch( def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost device: dr.DeviceEntry, host: ReolinkHost
) -> tuple[list[str], int | None, bool]: ) -> tuple[list[str], int | None, bool]:

View File

@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan
from reolink_aio.exceptions import ReolinkError from reolink_aio.exceptions import ReolinkError
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.components.reolink.const import (
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DOMAIN,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_PASSWORD, CONF_PASSWORD,
@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410"
TEST_ITEM_NUMBER = "P000" TEST_ITEM_NUMBER = "P000"
TEST_CAM_MODEL = "RLC-123" TEST_CAM_MODEL = "RLC-123"
TEST_DUO_MODEL = "Reolink Duo PoE" TEST_DUO_MODEL = "Reolink Duo PoE"
TEST_PRIVACY = True
@pytest.fixture @pytest.fixture
@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock = host_mock_class.return_value host_mock = host_mock_class.return_value
host_mock.get_host_data.return_value = None host_mock.get_host_data.return_value = None
host_mock.get_states.return_value = None host_mock.get_states.return_value = None
host_mock.supported.return_value = True
host_mock.check_new_firmware.return_value = False host_mock.check_new_firmware.return_value = False
host_mock.unsubscribe.return_value = True host_mock.unsubscribe.return_value = True
host_mock.logout.return_value = True host_mock.logout.return_value = True
@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]}
host_mock.checked_api_versions = {"GetEvents": 1} host_mock.checked_api_versions = {"GetEvents": 1}
host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]}
host_mock.get_raw_host_data.return_value = (
"{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}"
)
# enums # enums
host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode.return_value = 1
@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.baichuan.events_active = False host_mock.baichuan.events_active = False
host_mock.baichuan.privacy_mode.return_value = 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
@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT, CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
}, },
options={ options={
CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_PROTOCOL: DEFAULT_PROTOCOL,

View File

@ -18,7 +18,11 @@ from reolink_aio.exceptions import (
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.components.reolink.const import (
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DOMAIN,
)
from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.exceptions import ReolinkWebhookException
from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.components.reolink.host import DEFAULT_TIMEOUT
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -43,6 +47,7 @@ from .conftest import (
TEST_PASSWORD, TEST_PASSWORD,
TEST_PASSWORD2, TEST_PASSWORD2,
TEST_PORT, TEST_PORT,
TEST_PRIVACY,
TEST_USE_HTTPS, TEST_USE_HTTPS,
TEST_USERNAME, TEST_USERNAME,
TEST_USERNAME2, TEST_USERNAME2,
@ -82,6 +87,7 @@ async def test_config_flow_manual_success(
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT, CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
} }
assert result["options"] == { assert result["options"] == {
CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_PROTOCOL: DEFAULT_PROTOCOL,
@ -133,6 +139,7 @@ async def test_config_flow_privacy_success(
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT, CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
} }
assert result["options"] == { assert result["options"] == {
CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_PROTOCOL: DEFAULT_PROTOCOL,
@ -294,6 +301,7 @@ async def test_config_flow_errors(
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT, CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
} }
assert result["options"] == { assert result["options"] == {
CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_PROTOCOL: DEFAULT_PROTOCOL,
@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No
CONF_PASSWORD: TEST_PASSWORD, CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT, CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
} }
assert result["options"] == { assert result["options"] == {
CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_PROTOCOL: DEFAULT_PROTOCOL,

View File

@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback(
assert reolink_connect.get_states.call_count >= 1 assert reolink_connect.get_states.call_count >= 1
assert hass.states.get(entity_id).state == STATE_ON assert hass.states.get(entity_id).state == STATE_ON
async def test_remove(
hass: HomeAssistant,
reolink_connect: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test removing of the reolink integration."""
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_remove(config_entry.entry_id)