Reolink add binary sensors (#85654)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
starkillerOG 2023-01-20 22:27:59 +01:00 committed by GitHub
parent 7f4a727e10
commit 7e51aeb916
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 348 additions and 26 deletions

View File

@ -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

View File

@ -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,

View File

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

View File

@ -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

View File

@ -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}

View File

@ -4,6 +4,3 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
CONF_PROTOCOL = "protocol"
DEFAULT_PROTOCOL = "rtsp"
DEFAULT_TIMEOUT = 60

View File

@ -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),

View File

@ -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."""

View File

@ -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}", {})

View File

@ -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"]

View File

@ -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,
)