From a06b1eaf69ce333222c572cf8cb9bceafa7db211 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 27 Dec 2022 21:15:53 +0100 Subject: [PATCH] Add reolink IP NVR/Camera integration (#84081) Co-authored-by: J. Nick Koston --- .coveragerc | 5 + CODEOWNERS | 2 + homeassistant/components/reolink/__init__.py | 98 +++++++ homeassistant/components/reolink/camera.py | 63 +++++ .../components/reolink/config_flow.py | 137 ++++++++++ homeassistant/components/reolink/const.py | 13 + homeassistant/components/reolink/entity.py | 54 ++++ homeassistant/components/reolink/host.py | 168 ++++++++++++ .../components/reolink/manifest.json | 12 + homeassistant/components/reolink/strings.json | 33 +++ .../components/reolink/translations/en.json | 33 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/reolink/__init__.py | 1 + tests/components/reolink/test_config_flow.py | 250 ++++++++++++++++++ 17 files changed, 882 insertions(+) create mode 100644 homeassistant/components/reolink/__init__.py create mode 100644 homeassistant/components/reolink/camera.py create mode 100644 homeassistant/components/reolink/config_flow.py create mode 100644 homeassistant/components/reolink/const.py create mode 100644 homeassistant/components/reolink/entity.py create mode 100644 homeassistant/components/reolink/host.py create mode 100644 homeassistant/components/reolink/manifest.json create mode 100644 homeassistant/components/reolink/strings.json create mode 100644 homeassistant/components/reolink/translations/en.json create mode 100644 tests/components/reolink/__init__.py create mode 100644 tests/components/reolink/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5141b4a7086..317c2fe28fd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1049,6 +1049,11 @@ omit = homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* + homeassistant/components/reolink/__init__.py + homeassistant/components/reolink/camera.py + homeassistant/components/reolink/const.py + homeassistant/components/reolink/entity.py + homeassistant/components/reolink/host.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py homeassistant/components/rest/notify.py diff --git a/CODEOWNERS b/CODEOWNERS index e86433d2db0..04be07e4af5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -947,6 +947,8 @@ build.json @home-assistant/supervisor /tests/components/remote/ @home-assistant/core /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet +/homeassistant/components/reolink/ @starkillerOG @JimStar +/tests/components/reolink/ @starkillerOG @JimStar /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @MTrab @ShadowBr0ther diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py new file mode 100644 index 00000000000..2b565a6d4b8 --- /dev/null +++ b/homeassistant/components/reolink/__init__.py @@ -0,0 +1,98 @@ +"""Reolink integration for HomeAssistant.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aiohttp import ClientConnectorError +import async_timeout +from reolink_ip.exceptions import ApiError, InvalidContentTypeError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEVICE_UPDATE_INTERVAL, DOMAIN, PLATFORMS +from .host import ReolinkHost + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ReolinkData: + """Data for the Reolink integration.""" + + host: ReolinkHost + device_coordinator: DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Reolink from a config entry.""" + host = ReolinkHost(hass, dict(entry.data), dict(entry.options)) + + try: + if not await host.async_init(): + raise ConfigEntryNotReady( + f"Error while trying to setup {host.api.host}:{host.api.port}: failed to obtain data from device." + ) + except ( + ClientConnectorError, + asyncio.TimeoutError, + ApiError, + InvalidContentTypeError, + ) as err: + raise ConfigEntryNotReady( + f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".' + ) from err + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) + ) + + async def async_device_config_update(): + """Perform the update of the host config-state cache, and renew the ONVIF-subscription.""" + async with async_timeout.timeout(host.api.timeout): + await host.update_states() # Login session is implicitly updated here, so no need to explicitly do it in a timer + + coordinator_device_config_update = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"reolink.{host.api.nvr_name}", + update_method=async_device_config_update, + update_interval=timedelta(seconds=DEVICE_UPDATE_INTERVAL), + ) + # Fetch initial data so we have data when entities subscribe + await coordinator_device_config_update.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ReolinkData( + host=host, + device_coordinator=coordinator_device_config_update, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(entry_update_listener)) + + return True + + +async def entry_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update the configuration of the host entity.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + host: ReolinkHost = hass.data[DOMAIN][entry.entry_id].host + + await host.stop() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py new file mode 100644 index 00000000000..97369edcd64 --- /dev/null +++ b/homeassistant/components/reolink/camera.py @@ -0,0 +1,63 @@ +"""This component provides support for Reolink IP cameras.""" +from __future__ import annotations + +import logging + +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity +from .host import ReolinkHost + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up a Reolink IP Camera.""" + host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host + + cameras = [] + for channel in host.api.channels: + streams = ["sub", "main", "snapshots"] + if host.api.protocol == "rtmp": + streams.append("ext") + + for stream in streams: + cameras.append(ReolinkCamera(hass, config_entry, channel, stream)) + + async_add_devices(cameras, update_before_add=True) + + +class ReolinkCamera(ReolinkCoordinatorEntity, Camera): + """An implementation of a Reolink IP camera.""" + + _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + + def __init__(self, hass, config, channel, stream): + """Initialize Reolink camera stream.""" + ReolinkCoordinatorEntity.__init__(self, hass, config) + Camera.__init__(self) + + self._channel = channel + self._stream = stream + + self._attr_name = f"{self._host.api.camera_name(self._channel)} {self._stream}" + self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" + self._attr_entity_registry_enabled_default = stream == "sub" + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + return await self._host.api.get_stream_source(self._channel, self._stream) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + return await self._host.api.get_snapshot(self._channel) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py new file mode 100644 index 00000000000..169e2624d46 --- /dev/null +++ b/homeassistant/components/reolink/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for the Reolink camera component.""" +from __future__ import annotations + +import logging +from typing import cast + +from reolink_ip.exceptions import ApiError, CredentialsInvalidError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DOMAIN +from .host import ReolinkHost + +_LOGGER = logging.getLogger(__name__) + + +class ReolinkOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Reolink options.""" + + def __init__(self, config_entry): + """Initialize ReolinkOptionsFlowHandler.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None) -> FlowResult: + """Manage the Reolink options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_PROTOCOL, + default=self.config_entry.options.get( + CONF_PROTOCOL, DEFAULT_PROTOCOL + ), + ): vol.In(["rtsp", "rtmp"]), + } + ), + ) + + +class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Reolink device.""" + + VERSION = 1 + + host: ReolinkHost | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ReolinkOptionsFlowHandler: + """Options callback for Reolink.""" + return ReolinkOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the initial step.""" + errors = {} + placeholders = {} + + if user_input is not None: + try: + await self.async_obtain_host_settings(self.hass, user_input) + except CannotConnect: + errors[CONF_HOST] = "cannot_connect" + except CredentialsInvalidError: + errors[CONF_HOST] = "invalid_auth" + except ApiError as err: + placeholders["error"] = str(err) + errors[CONF_HOST] = "api_error" + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + placeholders["error"] = str(err) + errors[CONF_HOST] = "unknown" + + self.host = cast(ReolinkHost, self.host) + + if not errors: + user_input[CONF_PORT] = self.host.api.port + user_input[CONF_USE_HTTPS] = self.host.api.use_https + + await self.async_set_unique_id( + self.host.unique_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured(updates=user_input) + + return self.async_create_entry( + title=str(self.host.api.nvr_name), data=user_input + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default="admin"): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_HOST): str, + } + ) + if errors: + data_schema = data_schema.extend( + { + vol.Optional(CONF_PORT): cv.positive_int, + vol.Optional(CONF_USE_HTTPS): bool, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders=placeholders, + ) + + async def async_obtain_host_settings( + self, hass: core.HomeAssistant, user_input: dict + ): + """Initialize the Reolink host and get the host information.""" + host = ReolinkHost(hass, user_input, {}) + + try: + if not await host.async_init(): + raise CannotConnect + finally: + await host.stop() + + self.host = host + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py new file mode 100644 index 00000000000..95bd5da3c96 --- /dev/null +++ b/homeassistant/components/reolink/const.py @@ -0,0 +1,13 @@ +"""Constants for the Reolink Camera integration.""" + +DOMAIN = "reolink" +PLATFORMS = ["camera"] + +CONF_USE_HTTPS = "use_https" +CONF_PROTOCOL = "protocol" + +DEFAULT_PROTOCOL = "rtsp" +DEFAULT_TIMEOUT = 60 + +HOST = "host" +DEVICE_UPDATE_INTERVAL = 60 diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py new file mode 100644 index 00000000000..7e210114556 --- /dev/null +++ b/homeassistant/components/reolink/entity.py @@ -0,0 +1,54 @@ +"""Reolink parent entity class.""" + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ReolinkData +from .const import DOMAIN + + +class ReolinkCoordinatorEntity(CoordinatorEntity): + """Parent class for Reolink Entities.""" + + def __init__(self, hass, config): + """Initialize ReolinkCoordinatorEntity.""" + self._hass = hass + entry_data: ReolinkData = self._hass.data[DOMAIN][config.entry_id] + coordinator = entry_data.device_coordinator + super().__init__(coordinator) + + self._host = entry_data.host + self._channel = None + + @property + def device_info(self): + """Information about this entity/device.""" + http_s = "https" if self._host.api.use_https else "http" + conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + + if self._host.api.is_nvr and self._channel is not None: + return DeviceInfo( + identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, + via_device=(DOMAIN, self._host.unique_id), + name=self._host.api.camera_name(self._channel), + model=self._host.api.camera_model(self._channel), + manufacturer=self._host.api.manufacturer, + configuration_url=conf_url, + ) + + return DeviceInfo( + identifiers={(DOMAIN, self._host.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, + name=self._host.api.nvr_name, + model=self._host.api.model, + manufacturer=self._host.api.manufacturer, + hw_version=self._host.api.hardware_version, + sw_version=self._host.api.sw_version, + configuration_url=conf_url, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._host.api.session_active diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py new file mode 100644 index 00000000000..fbd88c94ccc --- /dev/null +++ b/homeassistant/components/reolink/host.py @@ -0,0 +1,168 @@ +"""This component encapsulates the NVR/camera API and subscription.""" +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +from reolink_ip.api import Host +from reolink_ip.exceptions import ( + ApiError, + CredentialsInvalidError, + InvalidContentTypeError, +) + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_PROTOCOL, DEFAULT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class ReolinkHost: + """The implementation of the Reolink Host class.""" + + def __init__( + self, + hass: HomeAssistant, + config: dict, + options: dict, + ) -> None: + """Initialize Reolink Host. Could be either NVR, or Camera.""" + self._hass: HomeAssistant = hass + + self._clientsession: aiohttp.ClientSession | None = None + self._unique_id: str | None = None + + cur_protocol = ( + DEFAULT_PROTOCOL if CONF_PROTOCOL not in options else options[CONF_PROTOCOL] + ) + + self._api = Host( + config[CONF_HOST], + config[CONF_USERNAME], + config[CONF_PASSWORD], + port=config.get(CONF_PORT), + use_https=config.get(CONF_USE_HTTPS), + protocol=cur_protocol, + timeout=DEFAULT_TIMEOUT, + ) + + @property + def unique_id(self): + """Create the unique ID, base for all entities.""" + return self._unique_id + + @property + def api(self): + """Return the API object.""" + return self._api + + async def async_init(self) -> bool: + """Connect to Reolink host.""" + self._api.expire_session() + + if not await self._api.get_host_data(): + return False + + if self._api.mac_address is None: + return False + + enable_onvif = None + enable_rtmp = None + enable_rtsp = None + + if not self._api.onvif_enabled: + _LOGGER.debug( + "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_onvif = True + + if not self._api.rtmp_enabled and self._api.protocol == "rtmp": + _LOGGER.debug( + "RTMP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtmp = True + elif not self._api.rtsp_enabled and self._api.protocol == "rtsp": + _LOGGER.debug( + "RTSP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtsp = True + + if enable_onvif or enable_rtmp or enable_rtsp: + if not await self._api.set_net_port( + enable_onvif=enable_onvif, + enable_rtmp=enable_rtmp, + enable_rtsp=enable_rtsp, + ): + if enable_onvif: + _LOGGER.error( + "Unable to switch on ONVIF on %s. You need it to be ON to receive notifications", + self._api.nvr_name, + ) + + if enable_rtmp: + _LOGGER.error( + "Unable to switch on RTMP on %s. You need it to be ON", + self._api.nvr_name, + ) + elif enable_rtsp: + _LOGGER.error( + "Unable to switch on RTSP on %s. You need it to be ON", + self._api.nvr_name, + ) + + if self._unique_id is None: + self._unique_id = format_mac(self._api.mac_address) + + return True + + async def update_states(self) -> bool: + """Call the API of the camera device to update the states.""" + return await self._api.get_states() + + async def disconnect(self): + """Disconnect from the API, so the connection will be released.""" + await self._api.unsubscribe_all() + + try: + await self._api.logout() + except aiohttp.ClientConnectorError as err: + _LOGGER.error( + "Reolink connection error while logging out for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + except asyncio.TimeoutError: + _LOGGER.error( + "Reolink connection timeout while logging out for host %s:%s", + self._api.host, + self._api.port, + ) + except ApiError as err: + _LOGGER.error( + "Reolink API error while logging out for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + except CredentialsInvalidError: + _LOGGER.error( + "Reolink credentials error while logging out for host %s:%s", + self._api.host, + self._api.port, + ) + except InvalidContentTypeError as err: + _LOGGER.error( + "Reolink content type error while logging out for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + + async def stop(self, event=None): + """Disconnect the API.""" + await self.disconnect() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json new file mode 100644 index 00000000000..4db59caa42f --- /dev/null +++ b/homeassistant/components/reolink/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "reolink", + "name": "Reolink IP NVR/camera", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/reolink", + "requirements": ["reolink-ip==0.0.40"], + "dependencies": ["webhook"], + "after_dependencies": ["http"], + "codeowners": ["@starkillerOG", "@JimStar"], + "iot_class": "local_polling", + "loggers": ["reolink-ip"] +} diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json new file mode 100644 index 00000000000..88211774240 --- /dev/null +++ b/homeassistant/components/reolink/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "use_https": "Enable HTTPS", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "api_error": "API error occurred: {error}", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]: {error}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "protocol": "Protocol" + } + } + } + } +} diff --git a/homeassistant/components/reolink/translations/en.json b/homeassistant/components/reolink/translations/en.json new file mode 100644 index 00000000000..dc16c105019 --- /dev/null +++ b/homeassistant/components/reolink/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "This camera is already configured" + }, + "error": { + "api_error": "API error occurred: {error}", + "cannot_connect": "Failed to connect with the camera", + "invalid_auth": "Invalid username or password", + "unknown": "Unexpected error: {error}" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port", + "use_https": "Enable HTTPS", + "username": "Username", + "password": "Password" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "protocol": "Protocol" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c728d1dd7b5..980f7f1897e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -336,6 +336,7 @@ FLOWS = { "rdw", "recollect_waste", "renault", + "reolink", "rfxtrx", "rhasspy", "ridwell", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d5c17c943c..40d6edc0d49 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4410,6 +4410,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "reolink": { + "name": "Reolink IP NVR/camera", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "repetier": { "name": "Repetier-Server", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index fb9cf6bda3c..de7d7a985d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2189,6 +2189,9 @@ regenmaschine==2022.11.0 # homeassistant.components.renault renault-api==0.1.11 +# homeassistant.components.reolink +reolink-ip==0.0.40 + # homeassistant.components.python_script restrictedpython==5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60a84f0353e..65fef23869c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1528,6 +1528,9 @@ regenmaschine==2022.11.0 # homeassistant.components.renault renault-api==0.1.11 +# homeassistant.components.reolink +reolink-ip==0.0.40 + # homeassistant.components.python_script restrictedpython==5.2 diff --git a/tests/components/reolink/__init__.py b/tests/components/reolink/__init__.py new file mode 100644 index 00000000000..45bcb2fab8c --- /dev/null +++ b/tests/components/reolink/__init__.py @@ -0,0 +1 @@ +"""Tests for the Reolink integration.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py new file mode 100644 index 00000000000..fcf9280eb9a --- /dev/null +++ b/tests/components/reolink/test_config_flow.py @@ -0,0 +1,250 @@ +"""Test the Reolink config flow.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from reolink_ip.exceptions import ApiError, CredentialsInvalidError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.reolink import const +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_HOST2 = "4.5.6.7" +TEST_USERNAME = "admin" +TEST_USERNAME2 = "username" +TEST_PASSWORD = "password" +TEST_PASSWORD2 = "new_password" +TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_PORT = 1234 +TEST_NVR_NAME = "test_reolink_name" +TEST_USE_HTTPS = True + + +def get_mock_info(error=None, host_data_return=True): + """Return a mock gateway info instance.""" + host_mock = Mock() + if error is None: + host_mock.get_host_data = AsyncMock(return_value=host_data_return) + else: + host_mock.get_host_data = AsyncMock(side_effect=error) + host_mock.unsubscribe_all = AsyncMock(return_value=True) + host_mock.logout = AsyncMock(return_value=True) + host_mock.mac_address = TEST_MAC + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + return host_mock + + +@pytest.fixture(name="reolink_connect", autouse=True) +def reolink_connect_fixture(mock_get_source_ip): + """Mock reolink connection and entry setup.""" + with patch( + "homeassistant.components.reolink.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.reolink.host.Host", return_value=get_mock_info() + ): + yield + + +async def test_config_flow_manual_success(hass): + """Successful flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + } + + +async def test_config_flow_errors(hass): + """Successful flow manually initialized by the user after some errors.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + host_mock = get_mock_info(host_data_return=False) + with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"host": "cannot_connect"} + + host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1)) + with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"host": "unknown"} + + host_mock = get_mock_info(error=CredentialsInvalidError("Test error")) + with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"host": "invalid_auth"} + + host_mock = get_mock_info(error=ApiError("Test error")) + with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"host": "api_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + } + + +async def test_options_flow(hass): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={const.CONF_PROTOCOL: "rtsp"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + const.CONF_PROTOCOL: "rtsp", + } + + +async def test_change_connection_settings(hass): + """Test changing connection settings by issuing a second user config flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.ABORT + assert config_entry.data[CONF_HOST] == TEST_HOST2 + assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2 + assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2