From 7e51aeb916e8ec024d561f71e2143228ad8c2ad2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 20 Jan 2023 22:27:59 +0100 Subject: [PATCH] Reolink add binary sensors (#85654) Co-authored-by: J. Nick Koston Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 9 +- .../components/reolink/binary_sensor.py | 168 ++++++++++++++++++ homeassistant/components/reolink/camera.py | 7 +- .../components/reolink/config_flow.py | 3 +- homeassistant/components/reolink/const.py | 3 - homeassistant/components/reolink/entity.py | 11 +- .../components/reolink/exceptions.py | 4 + homeassistant/components/reolink/host.py | 158 +++++++++++++++- .../components/reolink/manifest.json | 1 + tests/components/reolink/test_config_flow.py | 9 +- 11 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/reolink/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 8c60a99f026..bba5680bbf8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1064,6 +1064,7 @@ omit = homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* homeassistant/components/reolink/__init__.py + homeassistant/components/reolink/binary_sensor.py homeassistant/components/reolink/camera.py homeassistant/components/reolink/const.py homeassistant/components/reolink/entity.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index fee6567ab76..c20aff637ec 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -30,7 +30,7 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CAMERA] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA] DEVICE_UPDATE_INTERVAL = 60 @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await host.async_init() except UserNotAdmin as err: - raise ConfigEntryAuthFailed(err) from UserNotAdmin + raise ConfigEntryAuthFailed(err) from err except ( ClientConnectorError, asyncio.TimeoutError, @@ -62,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) as err: await host.stop() raise ConfigEntryNotReady( - f'Error while trying to setup {host.api.host}:{host.api.port}: "{str(err)}".' + f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" ) from err config_entry.async_on_unload( @@ -79,6 +79,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b f"Error updating Reolink {host.api.nvr_name}" ) from err + async with async_timeout.timeout(host.api.timeout): + await host.renew() + coordinator_device_config_update = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py new file mode 100644 index 00000000000..5e7718f4180 --- /dev/null +++ b/homeassistant/components/reolink/binary_sensor.py @@ -0,0 +1,168 @@ +"""This component provides support for Reolink binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from reolink_aio.api import ( + FACE_DETECTION_TYPE, + PERSON_DETECTION_TYPE, + PET_DETECTION_TYPE, + VEHICLE_DETECTION_TYPE, + Host, +) + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkBinarySensorEntityDescriptionMixin: + """Mixin values for Reolink binary sensor entities.""" + + value: Callable[[Host, int | None], bool] + + +@dataclass +class ReolinkBinarySensorEntityDescription( + BinarySensorEntityDescription, ReolinkBinarySensorEntityDescriptionMixin +): + """A class that describes binary sensor entities.""" + + icon: str = "mdi:motion-sensor" + icon_off: str = "mdi:motion-sensor-off" + supported: Callable[[Host, int | None], bool] = lambda host, ch: True + + +BINARY_SENSORS = ( + ReolinkBinarySensorEntityDescription( + key="motion", + name="Motion", + device_class=BinarySensorDeviceClass.MOTION, + value=lambda api, ch: api.motion_detected(ch), + ), + ReolinkBinarySensorEntityDescription( + key=FACE_DETECTION_TYPE, + name="Face", + icon="mdi:face-recognition", + value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE), + supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE), + ), + ReolinkBinarySensorEntityDescription( + key=PERSON_DETECTION_TYPE, + name="Person", + value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), + supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), + ), + ReolinkBinarySensorEntityDescription( + key=VEHICLE_DETECTION_TYPE, + name="Vehicle", + icon="mdi:car", + icon_off="mdi:car-off", + value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), + supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE), + ), + ReolinkBinarySensorEntityDescription( + key=PET_DETECTION_TYPE, + name="Pet", + icon="mdi:dog-side", + icon_off="mdi:dog-side-off", + value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), + supported=lambda api, ch: api.ai_supported(ch, PET_DETECTION_TYPE), + ), + ReolinkBinarySensorEntityDescription( + key="visitor", + name="Visitor", + icon="mdi:bell-ring-outline", + icon_off="mdi:doorbell", + value=lambda api, ch: api.visitor_detected(ch), + supported=lambda api, ch: api.is_doorbell_enabled(ch), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink IP Camera.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ReolinkBinarySensorEntity] = [] + for channel in reolink_data.host.api.channels: + entities.extend( + [ + ReolinkBinarySensorEntity(reolink_data, channel, entity_description) + for entity_description in BINARY_SENSORS + if entity_description.supported(reolink_data.host.api, channel) + ] + ) + + async_add_entities(entities) + + +class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): + """Base binary-sensor class for Reolink IP camera motion sensors.""" + + _attr_has_entity_name = True + entity_description: ReolinkBinarySensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkBinarySensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{self._channel}_{entity_description.key}" + ) + + @property + def icon(self) -> str | None: + """Icon of the sensor.""" + if self.is_on is False: + return self.entity_description.icon_off + return super().icon + + @property + def is_on(self) -> bool: + """State of the sensor.""" + return self.entity_description.value(self._host.api, self._channel) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._host.webhook_id}_{self._channel}", + self._async_handle_event, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._host.webhook_id}_all", + self._async_handle_event, + ) + ) + + async def _async_handle_event(self, event): + """Handle incoming event for motion detection.""" + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 5ad3679565f..5ccada7269d 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -34,9 +34,9 @@ async def async_setup_entry( stream_url = await host.api.get_stream_source(channel, stream) if stream_url is None and stream != "snapshots": continue - cameras.append(ReolinkCamera(reolink_data, config_entry, channel, stream)) + cameras.append(ReolinkCamera(reolink_data, channel, stream)) - async_add_entities(cameras, update_before_add=True) + async_add_entities(cameras) class ReolinkCamera(ReolinkCoordinatorEntity, Camera): @@ -48,12 +48,11 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): def __init__( self, reolink_data: ReolinkData, - config_entry: ConfigEntry, channel: int, stream: str, ) -> None: """Initialize Reolink camera stream.""" - ReolinkCoordinatorEntity.__init__(self, reolink_data, config_entry, channel) + ReolinkCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) self._stream = stream diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index faa9b28ac36..657d2fcca96 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -14,12 +14,13 @@ 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 .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, UserNotAdmin from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) +DEFAULT_PROTOCOL = "rtsp" DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 180c3ccae11..2a35a0f723d 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -4,6 +4,3 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_PROTOCOL = "protocol" - -DEFAULT_PROTOCOL = "rtsp" -DEFAULT_TIMEOUT = 60 diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 403ea278889..bcf39814c9a 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,7 +1,6 @@ """Reolink parent entity class.""" from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -11,12 +10,10 @@ from .const import DOMAIN class ReolinkCoordinatorEntity(CoordinatorEntity): - """Parent class for Reolink Entities.""" + """Parent class for Reolink hardware camera entities.""" - def __init__( - self, reolink_data: ReolinkData, config_entry: ConfigEntry, channel: int | None - ) -> None: - """Initialize ReolinkCoordinatorEntity.""" + def __init__(self, reolink_data: ReolinkData, channel: int) -> None: + """Initialize ReolinkCoordinatorEntity for a hardware camera.""" coordinator = reolink_data.device_coordinator super().__init__(coordinator) @@ -25,7 +22,7 @@ class ReolinkCoordinatorEntity(CoordinatorEntity): 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: + if self._host.api.is_nvr: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, via_device=(DOMAIN, self._host.unique_id), diff --git a/homeassistant/components/reolink/exceptions.py b/homeassistant/components/reolink/exceptions.py index 16fcffb064a..f3e9e0158cd 100644 --- a/homeassistant/components/reolink/exceptions.py +++ b/homeassistant/components/reolink/exceptions.py @@ -10,5 +10,9 @@ class ReolinkSetupException(ReolinkException): """Raised when setting up the Reolink host failed.""" +class ReolinkWebhookException(ReolinkException): + """Raised when registering the reolink webhook failed.""" + + class UserNotAdmin(ReolinkException): """Raised when user is not admin.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 8e7e435358f..3e0731ac8ce 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -7,15 +7,22 @@ import logging from typing import Any import aiohttp +from aiohttp.web import Request from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError +from homeassistant.components import webhook 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 homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DEFAULT_TIMEOUT -from .exceptions import ReolinkSetupException, UserNotAdmin +from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin + +DEFAULT_TIMEOUT = 60 +SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -45,6 +52,10 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.webhook_id: str | None = None + self._webhook_url: str | None = None + self._lost_subscription: bool = False + @property def unique_id(self) -> str: """Create the unique ID, base for all entities.""" @@ -67,7 +78,8 @@ class ReolinkHost: if not self._api.is_admin: await self.stop() raise UserNotAdmin( - f"User '{self._api.username}' has authorization level '{self._api.user_level}', only admin users can change camera settings" + f"User '{self._api.username}' has authorization level " + f"'{self._api.user_level}', only admin users can change camera settings" ) enable_onvif = None @@ -101,7 +113,8 @@ class ReolinkHost: except ReolinkError: if enable_onvif: _LOGGER.error( - "Failed to enable ONVIF on %s. Set it to ON to receive notifications", + "Failed to enable ONVIF on %s. " + "Set it to ON to receive notifications", self._api.nvr_name, ) @@ -118,6 +131,8 @@ class ReolinkHost: self._unique_id = format_mac(self._api.mac_address) + await self.subscribe() + async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" await self._api.get_states() @@ -151,4 +166,139 @@ class ReolinkHost: async def stop(self, event=None): """Disconnect the API.""" + await self.unregister_webhook() await self.disconnect() + + async def subscribe(self) -> None: + """Subscribe to motion events and register the webhook as a callback.""" + if self.webhook_id is None: + await self.register_webhook() + + if self._api.subscribed: + _LOGGER.debug( + "Host %s: is already subscribed to webhook %s", + self._api.host, + self._webhook_url, + ) + return + + if await self._api.subscribe(self._webhook_url): + _LOGGER.debug( + "Host %s: subscribed successfully to webhook %s", + self._api.host, + self._webhook_url, + ) + else: + raise ReolinkWebhookException( + f"Host {self._api.host}: webhook subscription failed" + ) + + async def renew(self) -> None: + """Renew the subscription of motion events (lease time is 15 minutes).""" + try: + await self._renew() + except ReolinkWebhookException as err: + if not self._lost_subscription: + self._lost_subscription = True + _LOGGER.error( + "Reolink %s event subscription lost: %s", + self._api.nvr_name, + str(err), + ) + else: + self._lost_subscription = False + + async def _renew(self) -> None: + """Execute the renew of the subscription.""" + if not self._api.subscribed: + _LOGGER.debug( + "Host %s: requested to renew a non-existing Reolink subscription, " + "trying to subscribe from scratch", + self._api.host, + ) + await self.subscribe() + return + + timer = self._api.renewtimer + if timer > SUBSCRIPTION_RENEW_THRESHOLD: + return + + if timer > 0: + if await self._api.renew(): + _LOGGER.debug( + "Host %s successfully renewed Reolink subscription", self._api.host + ) + return + _LOGGER.debug( + "Host %s: error renewing Reolink subscription, " + "trying to subscribe again", + self._api.host, + ) + + if not await self._api.subscribe(self._webhook_url): + raise ReolinkWebhookException( + f"Host {self._api.host}: webhook re-subscription failed" + ) + _LOGGER.debug( + "Host %s: Reolink re-subscription successful after it was expired", + self._api.host, + ) + + async def register_webhook(self) -> None: + """Register the webhook for motion events.""" + self.webhook_id = f"{DOMAIN}_{self.unique_id.replace(':', '')}" + event_id = self.webhook_id + + webhook.async_register( + self._hass, DOMAIN, event_id, event_id, self.handle_webhook + ) + + try: + base_url = get_url(self._hass, prefer_external=False) + except NoURLAvailableError: + try: + base_url = get_url(self._hass, prefer_external=True) + except NoURLAvailableError as err: + webhook.async_unregister(self._hass, event_id) + self.webhook_id = None + raise ReolinkWebhookException( + f"Error registering URL for webhook {event_id}: " + "HomeAssistant URL is not available" + ) from err + + webhook_path = webhook.async_generate_path(event_id) + self._webhook_url = f"{base_url}{webhook_path}" + + _LOGGER.debug("Registered webhook: %s", event_id) + + async def unregister_webhook(self): + """Unregister the webhook for motion events.""" + if self.webhook_id: + _LOGGER.debug("Unregistering webhook %s", self.webhook_id) + webhook.async_unregister(self._hass, self.webhook_id) + self.webhook_id = None + + async def handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ): + """Handle incoming webhook from Reolink for inbound messages and calls.""" + + _LOGGER.debug("Webhook '%s' called", webhook_id) + + if not request.body_exists: + _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) + return + + data = await request.text() + if not data: + _LOGGER.debug( + "Webhook '%s' triggered with unknown payload: %s", webhook_id, data + ) + return + + channel = await self._api.ONVIF_event_callback(data) + + if channel is None: + async_dispatcher_send(hass, f"{webhook_id}_all", {}) + else: + async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index e0447363ce9..1b746d98761 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", "requirements": ["reolink-aio==0.3.0"], + "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", "loggers": ["reolink_aio"] diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 95d43d59d71..090ae6f694b 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -7,6 +7,7 @@ from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkErr from homeassistant import config_entries, data_entry_flow from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers.device_registry import format_mac @@ -85,7 +86,7 @@ async def test_config_flow_manual_success(hass): const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL, + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -195,7 +196,7 @@ async def test_config_flow_errors(hass): const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL, + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -250,7 +251,7 @@ async def test_change_connection_settings(hass): const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL, + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -293,7 +294,7 @@ async def test_reauth(hass): const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: const.DEFAULT_PROTOCOL, + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, )