Reolink add TCP push event connection as primary method (#129490)

pull/118757/head
starkillerOG 2024-10-30 14:34:32 +01:00 committed by GitHub
parent ed6123a3e6
commit a6189106e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 51 deletions

View File

@ -42,29 +42,34 @@ class ReolinkBinarySensorEntityDescription(
BINARY_PUSH_SENSORS = (
ReolinkBinarySensorEntityDescription(
key="motion",
cmd_id=33,
device_class=BinarySensorDeviceClass.MOTION,
value=lambda api, ch: api.motion_detected(ch),
),
ReolinkBinarySensorEntityDescription(
key=FACE_DETECTION_TYPE,
cmd_id=33,
translation_key="face",
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,
cmd_id=33,
translation_key="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,
cmd_id=33,
translation_key="vehicle",
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,
cmd_id=33,
translation_key="pet",
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: (
@ -74,18 +79,21 @@ BINARY_PUSH_SENSORS = (
),
ReolinkBinarySensorEntityDescription(
key=PET_DETECTION_TYPE,
cmd_id=33,
translation_key="animal",
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: api.supported(ch, "ai_animal"),
),
ReolinkBinarySensorEntityDescription(
key=PACKAGE_DETECTION_TYPE,
cmd_id=33,
translation_key="package",
value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE),
),
ReolinkBinarySensorEntityDescription(
key="visitor",
cmd_id=33,
translation_key="visitor",
value=lambda api, ch: api.visitor_detected(ch),
supported=lambda api, ch: api.is_doorbell(ch),

View File

@ -7,6 +7,7 @@ from dataclasses import dataclass
from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
@ -23,6 +24,7 @@ class ReolinkEntityDescription(EntityDescription):
"""A class that describes entities for Reolink."""
cmd_key: str | None = None
cmd_id: int | None = None
@dataclass(frozen=True, kw_only=True)
@ -90,18 +92,35 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
"""Return True if entity is available."""
return self._host.api.session_active and super().available
@callback
def _push_callback(self) -> None:
"""Handle incoming TCP push event."""
self.async_write_ha_state()
def register_callback(self, unique_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback( # pragma: no cover
unique_id, self._push_callback, cmd_id
)
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
cmd_key = self.entity_description.cmd_key
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:
self.register_callback(self._attr_unique_id, cmd_id)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
cmd_key = self.entity_description.cmd_key
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:
self._host.api.baichuan.unregister_callback(self._attr_unique_id)
await super().async_will_remove_from_hass()
@ -160,6 +179,12 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Return True if entity is available."""
return super().available and self._host.api.camera_online(self._channel)
def register_callback(self, unique_id: str, cmd_id) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback(
unique_id, self._push_callback, cmd_id, self._channel
)
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()

View File

@ -41,6 +41,7 @@ from .exceptions import (
)
DEFAULT_TIMEOUT = 30
FIRST_TCP_PUSH_TIMEOUT = 10
FIRST_ONVIF_TIMEOUT = 10
FIRST_ONVIF_LONG_POLL_TIMEOUT = 90
SUBSCRIPTION_RENEW_THRESHOLD = 300
@ -105,6 +106,7 @@ class ReolinkHost:
self._long_poll_received: bool = False
self._long_poll_error: bool = False
self._cancel_poll: CALLBACK_TYPE | None = None
self._cancel_tcp_push_check: CALLBACK_TYPE | None = None
self._cancel_onvif_check: CALLBACK_TYPE | None = None
self._cancel_long_poll_check: CALLBACK_TYPE | None = None
self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True)
@ -220,48 +222,13 @@ class ReolinkHost:
else:
self._unique_id = format_mac(self._api.mac_address)
if self._onvif_push_supported:
try:
await self.subscribe()
await self._api.baichuan.subscribe_events()
except ReolinkError:
self._onvif_push_supported = False
self.unregister_webhook()
await self._api.unsubscribe()
await self._async_check_tcp_push()
else:
if self._api.supported(None, "initial_ONVIF_state"):
_LOGGER.debug(
"Waiting for initial ONVIF state on webhook '%s'",
self._webhook_url,
)
else:
_LOGGER.debug(
"Camera model %s most likely does not push its initial state"
" upon ONVIF subscription, do not check",
self._api.model,
)
self._cancel_onvif_check = async_call_later(
self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif
)
if not self._onvif_push_supported:
_LOGGER.debug(
"Camera model %s does not support ONVIF push, using ONVIF long polling instead",
self._api.model,
)
try:
await self._async_start_long_polling(initial=True)
except NotSupportedError:
_LOGGER.debug(
"Camera model %s does not support ONVIF long polling, using fast polling instead",
self._api.model,
)
self._onvif_long_poll_supported = False
await self._api.unsubscribe()
await self._async_poll_all_motion()
else:
self._cancel_long_poll_check = async_call_later(
self._hass,
FIRST_ONVIF_LONG_POLL_TIMEOUT,
self._async_check_onvif_long_poll,
self._cancel_tcp_push_check = async_call_later(
self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push
)
ch_list: list[int | None] = [None]
@ -294,6 +261,67 @@ class ReolinkHost:
else:
ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}")
async def _async_check_tcp_push(self, *_) -> None:
"""Check the TCP push subscription."""
if self._api.baichuan.events_active:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
self._cancel_tcp_push_check = None
return
_LOGGER.debug(
"Reolink %s, did not receive initial TCP push event after %i seconds",
self._api.nvr_name,
FIRST_TCP_PUSH_TIMEOUT,
)
if self._onvif_push_supported:
try:
await self.subscribe()
except ReolinkError:
self._onvif_push_supported = False
self.unregister_webhook()
await self._api.unsubscribe()
else:
if self._api.supported(None, "initial_ONVIF_state"):
_LOGGER.debug(
"Waiting for initial ONVIF state on webhook '%s'",
self._webhook_url,
)
else:
_LOGGER.debug(
"Camera model %s most likely does not push its initial state"
" upon ONVIF subscription, do not check",
self._api.model,
)
self._cancel_onvif_check = async_call_later(
self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif
)
# start long polling if ONVIF push failed immediately
if not self._onvif_push_supported:
_LOGGER.debug(
"Camera model %s does not support ONVIF push, using ONVIF long polling instead",
self._api.model,
)
try:
await self._async_start_long_polling(initial=True)
except NotSupportedError:
_LOGGER.debug(
"Camera model %s does not support ONVIF long polling, using fast polling instead",
self._api.model,
)
self._onvif_long_poll_supported = False
await self._api.unsubscribe()
await self._async_poll_all_motion()
else:
self._cancel_long_poll_check = async_call_later(
self._hass,
FIRST_ONVIF_LONG_POLL_TIMEOUT,
self._async_check_onvif_long_poll,
)
self._cancel_tcp_push_check = None
async def _async_check_onvif(self, *_) -> None:
"""Check the ONVIF subscription."""
if self._webhook_reachable:
@ -391,6 +419,16 @@ class ReolinkHost:
async def disconnect(self) -> None:
"""Disconnect from the API, so the connection will be released."""
try:
await self._api.baichuan.unsubscribe_events()
except ReolinkError as err:
_LOGGER.error(
"Reolink error while unsubscribing Baichuan from host %s:%s: %s",
self._api.host,
self._api.port,
err,
)
try:
await self._api.unsubscribe()
except ReolinkError as err:
@ -461,6 +499,9 @@ class ReolinkHost:
if self._cancel_poll is not None:
self._cancel_poll()
self._cancel_poll = None
if self._cancel_tcp_push_check is not None:
self._cancel_tcp_push_check()
self._cancel_tcp_push_check = None
if self._cancel_onvif_check is not None:
self._cancel_onvif_check()
self._cancel_onvif_check = None
@ -494,8 +535,13 @@ class ReolinkHost:
async def renew(self) -> None:
"""Renew the subscription of motion events (lease time is 15 minutes)."""
if self._api.baichuan.events_active and self._api.subscribed(SubType.push):
# TCP push active, unsubscribe from ONVIF push because not needed
self.unregister_webhook()
await self._api.unsubscribe()
try:
if self._onvif_push_supported:
if self._onvif_push_supported and not self._api.baichuan.events_active:
await self._renew(SubType.push)
if self._onvif_long_poll_supported and self._long_poll_task is not None:
@ -608,7 +654,8 @@ class ReolinkHost:
"""Use ONVIF long polling to immediately receive events."""
# This task will be cancelled once _async_stop_long_polling is called
while True:
if self._webhook_reachable:
if self._api.baichuan.events_active or self._webhook_reachable:
# TCP push or ONVIF push working, stop long polling
self._long_poll_task = None
await self._async_stop_long_polling()
return
@ -642,8 +689,12 @@ class ReolinkHost:
async def _async_poll_all_motion(self, *_) -> None:
"""Poll motion and AI states until the first ONVIF push is received."""
if self._webhook_reachable or self._long_poll_received:
# ONVIF push or long polling is working, stop fast polling
if (
self._api.baichuan.events_active
or self._webhook_reachable
or self._long_poll_received
):
# TCP push, ONVIF push or long polling is working, stop fast polling
self._cancel_poll = None
return
@ -747,6 +798,8 @@ class ReolinkHost:
@property
def event_connection(self) -> str:
"""Type of connection to receive events."""
if self._api.baichuan.events_active:
return "TCP push"
if self._webhook_reachable:
return "ONVIF push"
if self._long_poll_received:

View File

@ -1,10 +1,12 @@
"""Setup the Reolink tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
import pytest
from reolink_aio.api import Chime
from reolink_aio.baichuan import Baichuan
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN
@ -118,6 +120,12 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.doorbell_led_list.return_value = ["stayoff", "auto"]
host_mock.auto_track_method.return_value = 3
host_mock.daynight_state.return_value = "Black&White"
# Baichuan
host_mock.baichuan = create_autospec(Baichuan)
# Disable tcp push by default for tests
host_mock.baichuan.events_active = False
host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error")
yield host_mock_class

View File

@ -1,5 +1,6 @@
"""Test the Reolink binary sensor platform."""
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
@ -8,9 +9,8 @@ from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME
from .conftest import TEST_DUO_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
@ -22,7 +22,6 @@ async def test_motion_sensor(
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test binary sensor entity with motion sensor."""
reolink_connect.model = TEST_DUO_MODEL
@ -42,7 +41,7 @@ async def test_motion_sensor(
assert hass.states.get(entity_id).state == STATE_OFF
# test webhook callback
# test ONVIF webhook callback
reolink_connect.motion_detected.return_value = True
reolink_connect.ONVIF_event_callback.return_value = [0]
webhook_id = config_entry.runtime_data.host.webhook_id
@ -50,3 +49,43 @@ async def test_motion_sensor(
await client.post(f"/api/webhook/{webhook_id}", data="test_data")
assert hass.states.get(entity_id).state == STATE_ON
async def test_tcp_callback(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test tcp callback using motion sensor."""
class callback_mock_class:
callback_func = None
def register_callback(
self, callback_id: str, callback: Callable[[], None], *args, **key_args
) -> None:
if callback_id.endswith("_motion"):
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.motion_detected.return_value = True
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]):
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.BINARY_SENSOR}.{TEST_NVR_NAME}_motion"
assert hass.states.get(entity_id).state == STATE_ON
# simulate a TCP push callback
reolink_connect.motion_detected.return_value = False
assert callback_mock.callback_func is not None
callback_mock.callback_func()
assert hass.states.get(entity_id).state == STATE_OFF

View File

@ -14,12 +14,14 @@ from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL
from homeassistant.components.reolink.host import (
FIRST_ONVIF_LONG_POLL_TIMEOUT,
FIRST_ONVIF_TIMEOUT,
FIRST_TCP_PUSH_TIMEOUT,
LONG_POLL_COOLDOWN,
LONG_POLL_ERROR_COOLDOWN,
POLL_INTERVAL_NO_PUSH,
)
from homeassistant.components.webhook import async_handle_webhook
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -31,6 +33,56 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_setup_with_tcp_push(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test successful setup of the integration with TCP push callbacks."""
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
freezer.tick(timedelta(seconds=FIRST_TCP_PUSH_TIMEOUT))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# ONVIF push subscription not called
assert not reolink_connect.subscribe.called
reolink_connect.baichuan.events_active = False
reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error")
async def test_unloading_with_tcp_push(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test successful unloading of the integration with TCP push callbacks."""
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
reolink_connect.baichuan.unsubscribe_events.side_effect = ReolinkError("Test error")
# Unload the config entry
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
reolink_connect.baichuan.events_active = False
reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error")
reolink_connect.baichuan.unsubscribe_events.reset_mock(side_effect=True)
async def test_webhook_callback(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
@ -402,3 +454,8 @@ async def test_diagnostics_event_connection(
diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert diag["event connection"] == "ONVIF push"
# set TCP push as active
reolink_connect.baichuan.events_active = True
diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert diag["event connection"] == "TCP push"