Home Assistant Unofficial Reference 2024.12.1
host.py
Go to the documentation of this file.
1 """Module which encapsulates the NVR/camera API and subscription."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import defaultdict
7 from collections.abc import Mapping
8 import logging
9 from time import time
10 from typing import Any, Literal
11 
12 import aiohttp
13 from aiohttp.web import Request
14 from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
15 from reolink_aio.enums import SubType
16 from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
17 
18 from homeassistant.components import webhook
19 from homeassistant.const import (
20  CONF_HOST,
21  CONF_PASSWORD,
22  CONF_PORT,
23  CONF_PROTOCOL,
24  CONF_USERNAME,
25 )
26 from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
27 from homeassistant.helpers import issue_registry as ir
28 from homeassistant.helpers.aiohttp_client import async_get_clientsession
29 from homeassistant.helpers.device_registry import format_mac
30 from homeassistant.helpers.dispatcher import async_dispatcher_send
31 from homeassistant.helpers.event import async_call_later
32 from homeassistant.helpers.network import NoURLAvailableError, get_url
33 from homeassistant.util.ssl import SSLCipherList
34 
35 from .const import CONF_USE_HTTPS, DOMAIN
36 from .exceptions import (
37  PasswordIncompatible,
38  ReolinkSetupException,
39  ReolinkWebhookException,
40  UserNotAdmin,
41 )
42 
43 DEFAULT_TIMEOUT = 30
44 FIRST_TCP_PUSH_TIMEOUT = 10
45 FIRST_ONVIF_TIMEOUT = 10
46 FIRST_ONVIF_LONG_POLL_TIMEOUT = 90
47 SUBSCRIPTION_RENEW_THRESHOLD = 300
48 POLL_INTERVAL_NO_PUSH = 5
49 LONG_POLL_COOLDOWN = 0.75
50 LONG_POLL_ERROR_COOLDOWN = 30
51 
52 # Conserve battery by not waking the battery cameras each minute during normal update
53 # Most props are cached in the Home Hub and updated, but some are skipped
54 BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds
55 
56 _LOGGER = logging.getLogger(__name__)
57 
58 
60  """The implementation of the Reolink Host class."""
61 
62  def __init__(
63  self,
64  hass: HomeAssistant,
65  config: Mapping[str, Any],
66  options: Mapping[str, Any],
67  ) -> None:
68  """Initialize Reolink Host. Could be either NVR, or Camera."""
69  self._hass: HomeAssistant = hass
70  self._unique_id_unique_id: str = ""
71 
72  def get_aiohttp_session() -> aiohttp.ClientSession:
73  """Return the HA aiohttp session."""
75  hass,
76  verify_ssl=False,
77  ssl_cipher=SSLCipherList.INSECURE,
78  )
79 
80  self._api_api = Host(
81  config[CONF_HOST],
82  config[CONF_USERNAME],
83  config[CONF_PASSWORD],
84  port=config.get(CONF_PORT),
85  use_https=config.get(CONF_USE_HTTPS),
86  protocol=options[CONF_PROTOCOL],
87  timeout=DEFAULT_TIMEOUT,
88  aiohttp_get_session_callback=get_aiohttp_session,
89  )
90 
91  self.last_wakelast_wake: float = 0
92  self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict(
93  lambda: defaultdict(int)
94  )
95  self.firmware_ch_list: list[int | None] = []
96 
97  self.starting: bool = True
98  self.credential_errors: int = 0
99 
100  self.webhook_idwebhook_id: str | None = None
101  self._onvif_push_supported_onvif_push_supported: bool = True
102  self._onvif_long_poll_supported_onvif_long_poll_supported: bool = True
103  self._base_url_base_url: str = ""
104  self._webhook_url_webhook_url: str = ""
105  self._webhook_reachable_webhook_reachable: bool = False
106  self._long_poll_received_long_poll_received: bool = False
107  self._long_poll_error_long_poll_error: bool = False
108  self._cancel_poll_cancel_poll: CALLBACK_TYPE | None = None
109  self._cancel_tcp_push_check_cancel_tcp_push_check: CALLBACK_TYPE | None = None
110  self._cancel_onvif_check_cancel_onvif_check: CALLBACK_TYPE | None = None
111  self._cancel_long_poll_check_cancel_long_poll_check: CALLBACK_TYPE | None = None
112  self._poll_job_poll_job = HassJob(self._async_poll_all_motion_async_poll_all_motion, cancel_on_shutdown=True)
113  self._fast_poll_error_fast_poll_error: bool = False
114  self._long_poll_task_long_poll_task: asyncio.Task | None = None
115  self._lost_subscription_lost_subscription: bool = False
116 
117  @callback
118  def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None:
119  """Register the command to update the state."""
120  self.update_cmd[cmd][channel] += 1
121 
122  @callback
123  def async_unregister_update_cmd(self, cmd: str, channel: int | None = None) -> None:
124  """Unregister the command to update the state."""
125  self.update_cmd[cmd][channel] -= 1
126  if not self.update_cmd[cmd][channel]:
127  del self.update_cmd[cmd][channel]
128  if not self.update_cmd[cmd]:
129  del self.update_cmd[cmd]
130 
131  @property
132  def unique_id(self) -> str:
133  """Create the unique ID, base for all entities."""
134  return self._unique_id_unique_id
135 
136  @property
137  def api(self) -> Host:
138  """Return the API object."""
139  return self._api_api
140 
141  async def async_init(self) -> None:
142  """Connect to Reolink host."""
143  if not self._api_api.valid_password():
144  raise PasswordIncompatible(
145  "Reolink password contains incompatible special character, "
146  "please change the password to only contain characters: "
147  f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}"
148  )
149 
150  await self._api_api.get_host_data()
151 
152  if self._api_api.mac_address is None:
153  raise ReolinkSetupException("Could not get mac address")
154 
155  if not self._api_api.is_admin:
156  raise UserNotAdmin(
157  f"User '{self._api.username}' has authorization level "
158  f"'{self._api.user_level}', only admin users can change camera settings"
159  )
160 
161  onvif_supported = self._api_api.supported(None, "ONVIF")
162  self._onvif_push_supported_onvif_push_supported = onvif_supported
163  self._onvif_long_poll_supported_onvif_long_poll_supported = onvif_supported
164 
165  enable_rtsp = None
166  enable_onvif = None
167  enable_rtmp = None
168 
169  if not self._api_api.rtsp_enabled:
170  _LOGGER.debug(
171  "RTSP is disabled on %s, trying to enable it", self._api_api.nvr_name
172  )
173  enable_rtsp = True
174 
175  if not self._api_api.onvif_enabled and onvif_supported:
176  _LOGGER.debug(
177  "ONVIF is disabled on %s, trying to enable it", self._api_api.nvr_name
178  )
179  enable_onvif = True
180 
181  if not self._api_api.rtmp_enabled and self._api_api.protocol == "rtmp":
182  _LOGGER.debug(
183  "RTMP is disabled on %s, trying to enable it", self._api_api.nvr_name
184  )
185  enable_rtmp = True
186 
187  if enable_onvif or enable_rtmp or enable_rtsp:
188  try:
189  await self._api_api.set_net_port(
190  enable_onvif=enable_onvif,
191  enable_rtmp=enable_rtmp,
192  enable_rtsp=enable_rtsp,
193  )
194  except ReolinkError:
195  ports = ""
196  if enable_rtsp:
197  ports += "RTSP "
198 
199  if enable_onvif:
200  ports += "ONVIF "
201 
202  if enable_rtmp:
203  ports += "RTMP "
204 
205  ir.async_create_issue(
206  self._hass,
207  DOMAIN,
208  "enable_port",
209  is_fixable=False,
210  severity=ir.IssueSeverity.WARNING,
211  translation_key="enable_port",
212  translation_placeholders={
213  "name": self._api_api.nvr_name,
214  "ports": ports,
215  "info_link": "https://support.reolink.com/hc/en-us/articles/900004435763-How-to-Set-up-Reolink-Ports-Settings-via-Reolink-Client-New-Client-",
216  },
217  )
218  else:
219  ir.async_delete_issue(self._hass, DOMAIN, "enable_port")
220 
221  if self._api_api.supported(None, "UID"):
222  self._unique_id_unique_id = self._api_api.uid
223  else:
224  self._unique_id_unique_id = format_mac(self._api_api.mac_address)
225 
226  try:
227  await self._api_api.baichuan.subscribe_events()
228  except ReolinkError:
229  await self._async_check_tcp_push_async_check_tcp_push()
230  else:
231  self._cancel_tcp_push_check_cancel_tcp_push_check = async_call_later(
232  self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push_async_check_tcp_push
233  )
234 
235  ch_list: list[int | None] = [None]
236  if self._api_api.is_nvr:
237  ch_list.extend(self._api_api.channels)
238  for ch in ch_list:
239  if not self._api_api.supported(ch, "firmware"):
240  continue
241 
242  key = ch if ch is not None else "host"
243  if self._api_api.camera_sw_version_update_required(ch):
244  ir.async_create_issue(
245  self._hass,
246  DOMAIN,
247  f"firmware_update_{key}",
248  is_fixable=False,
249  severity=ir.IssueSeverity.WARNING,
250  translation_key="firmware_update",
251  translation_placeholders={
252  "required_firmware": self._api_api.camera_sw_version_required(
253  ch
254  ).version_string,
255  "current_firmware": self._api_api.camera_sw_version(ch),
256  "model": self._api_api.camera_model(ch),
257  "hw_version": self._api_api.camera_hardware_version(ch),
258  "name": self._api_api.camera_name(ch),
259  "download_link": "https://reolink.com/download-center/",
260  },
261  )
262  else:
263  ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}")
264 
265  async def _async_check_tcp_push(self, *_: Any) -> None:
266  """Check the TCP push subscription."""
267  if self._api_api.baichuan.events_active:
268  ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
269  self._cancel_tcp_push_check_cancel_tcp_push_check = None
270  return
271 
272  _LOGGER.debug(
273  "Reolink %s, did not receive initial TCP push event after %i seconds",
274  self._api_api.nvr_name,
275  FIRST_TCP_PUSH_TIMEOUT,
276  )
277 
278  if self._onvif_push_supported_onvif_push_supported:
279  try:
280  await self.subscribesubscribe()
281  except ReolinkError:
282  self._onvif_push_supported_onvif_push_supported = False
283  self.unregister_webhookunregister_webhook()
284  await self._api_api.unsubscribe()
285  else:
286  if self._api_api.supported(None, "initial_ONVIF_state"):
287  _LOGGER.debug(
288  "Waiting for initial ONVIF state on webhook '%s'",
289  self._webhook_url_webhook_url,
290  )
291  else:
292  _LOGGER.debug(
293  "Camera model %s most likely does not push its initial state"
294  " upon ONVIF subscription, do not check",
295  self._api_api.model,
296  )
297  self._cancel_onvif_check_cancel_onvif_check = async_call_later(
298  self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif_async_check_onvif
299  )
300 
301  # start long polling if ONVIF push failed immediately
302  if not self._onvif_push_supported_onvif_push_supported:
303  _LOGGER.debug(
304  "Camera model %s does not support ONVIF push, using ONVIF long polling instead",
305  self._api_api.model,
306  )
307  try:
308  await self._async_start_long_polling_async_start_long_polling(initial=True)
309  except NotSupportedError:
310  _LOGGER.debug(
311  "Camera model %s does not support ONVIF long polling, using fast polling instead",
312  self._api_api.model,
313  )
314  self._onvif_long_poll_supported_onvif_long_poll_supported = False
315  await self._api_api.unsubscribe()
316  await self._async_poll_all_motion_async_poll_all_motion()
317  else:
318  self._cancel_long_poll_check_cancel_long_poll_check = async_call_later(
319  self._hass,
320  FIRST_ONVIF_LONG_POLL_TIMEOUT,
321  self._async_check_onvif_long_poll_async_check_onvif_long_poll,
322  )
323 
324  self._cancel_tcp_push_check_cancel_tcp_push_check = None
325 
326  async def _async_check_onvif(self, *_: Any) -> None:
327  """Check the ONVIF subscription."""
328  if self._webhook_reachable_webhook_reachable:
329  ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
330  self._cancel_onvif_check_cancel_onvif_check = None
331  return
332  if self._api_api.supported(None, "initial_ONVIF_state"):
333  _LOGGER.debug(
334  "Did not receive initial ONVIF state on webhook '%s' after %i seconds",
335  self._webhook_url_webhook_url,
336  FIRST_ONVIF_TIMEOUT,
337  )
338 
339  # ONVIF push is not received, start long polling and schedule check
340  await self._async_start_long_polling_async_start_long_polling()
341  self._cancel_long_poll_check_cancel_long_poll_check = async_call_later(
342  self._hass, FIRST_ONVIF_LONG_POLL_TIMEOUT, self._async_check_onvif_long_poll_async_check_onvif_long_poll
343  )
344 
345  self._cancel_onvif_check_cancel_onvif_check = None
346 
347  async def _async_check_onvif_long_poll(self, *_: Any) -> None:
348  """Check if ONVIF long polling is working."""
349  if not self._long_poll_received_long_poll_received:
350  _LOGGER.debug(
351  "Did not receive state through ONVIF long polling after %i seconds",
352  FIRST_ONVIF_LONG_POLL_TIMEOUT,
353  )
354  ir.async_create_issue(
355  self._hass,
356  DOMAIN,
357  "webhook_url",
358  is_fixable=False,
359  severity=ir.IssueSeverity.WARNING,
360  translation_key="webhook_url",
361  translation_placeholders={
362  "name": self._api_api.nvr_name,
363  "base_url": self._base_url_base_url,
364  "network_link": "https://my.home-assistant.io/redirect/network/",
365  },
366  )
367 
368  if self._base_url_base_url.startswith("https"):
369  ir.async_create_issue(
370  self._hass,
371  DOMAIN,
372  "https_webhook",
373  is_fixable=False,
374  severity=ir.IssueSeverity.WARNING,
375  translation_key="https_webhook",
376  translation_placeholders={
377  "base_url": self._base_url_base_url,
378  "network_link": "https://my.home-assistant.io/redirect/network/",
379  },
380  )
381  else:
382  ir.async_delete_issue(self._hass, DOMAIN, "https_webhook")
383 
384  if self._hass.config.api is not None and self._hass.config.api.use_ssl:
385  ir.async_create_issue(
386  self._hass,
387  DOMAIN,
388  "ssl",
389  is_fixable=False,
390  severity=ir.IssueSeverity.WARNING,
391  translation_key="ssl",
392  translation_placeholders={
393  "ssl_link": "https://www.home-assistant.io/integrations/http/#ssl_certificate",
394  "base_url": self._base_url_base_url,
395  "network_link": "https://my.home-assistant.io/redirect/network/",
396  "nginx_link": "https://github.com/home-assistant/addons/tree/master/nginx_proxy",
397  },
398  )
399  else:
400  ir.async_delete_issue(self._hass, DOMAIN, "ssl")
401  else:
402  ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
403  ir.async_delete_issue(self._hass, DOMAIN, "https_webhook")
404  ir.async_delete_issue(self._hass, DOMAIN, "ssl")
405 
406  # If no ONVIF push or long polling state is received, start fast polling
407  await self._async_poll_all_motion_async_poll_all_motion()
408 
409  self._cancel_long_poll_check_cancel_long_poll_check = None
410 
411  async def update_states(self) -> None:
412  """Call the API of the camera device to update the internal states."""
413  wake = False
414  if time() - self.last_wakelast_wake > BATTERY_WAKE_UPDATE_INTERVAL:
415  # wake the battery cameras for a complete update
416  wake = True
417  self.last_wakelast_wake = time()
418 
419  await self._api_api.get_states(cmd_list=self.update_cmd, wake=wake)
420 
421  async def disconnect(self) -> None:
422  """Disconnect from the API, so the connection will be released."""
423  try:
424  await self._api_api.baichuan.unsubscribe_events()
425  except ReolinkError as err:
426  _LOGGER.error(
427  "Reolink error while unsubscribing Baichuan from host %s:%s: %s",
428  self._api_api.host,
429  self._api_api.port,
430  err,
431  )
432 
433  try:
434  await self._api_api.unsubscribe()
435  except ReolinkError as err:
436  _LOGGER.error(
437  "Reolink error while unsubscribing from host %s:%s: %s",
438  self._api_api.host,
439  self._api_api.port,
440  err,
441  )
442 
443  try:
444  await self._api_api.logout()
445  except ReolinkError as err:
446  _LOGGER.error(
447  "Reolink error while logging out for host %s:%s: %s",
448  self._api_api.host,
449  self._api_api.port,
450  err,
451  )
452 
453  async def _async_start_long_polling(self, initial: bool = False) -> None:
454  """Start ONVIF long polling task."""
455  if self._long_poll_task_long_poll_task is None:
456  try:
457  await self._api_api.subscribe(sub_type=SubType.long_poll)
458  except NotSupportedError as err:
459  if initial:
460  raise
461  # make sure the long_poll_task is always created to try again later
462  if not self._lost_subscription_lost_subscription:
463  self._lost_subscription_lost_subscription = True
464  _LOGGER.error(
465  "Reolink %s event long polling subscription lost: %s",
466  self._api_api.nvr_name,
467  err,
468  )
469  except ReolinkError as err:
470  # make sure the long_poll_task is always created to try again later
471  if not self._lost_subscription_lost_subscription:
472  self._lost_subscription_lost_subscription = True
473  _LOGGER.error(
474  "Reolink %s event long polling subscription lost: %s",
475  self._api_api.nvr_name,
476  err,
477  )
478  else:
479  self._lost_subscription_lost_subscription = False
480  self._long_poll_task_long_poll_task = asyncio.create_task(self._async_long_polling_async_long_polling())
481 
482  async def _async_stop_long_polling(self) -> None:
483  """Stop ONVIF long polling task."""
484  if self._long_poll_task_long_poll_task is not None:
485  self._long_poll_task_long_poll_task.cancel()
486  self._long_poll_task_long_poll_task = None
487 
488  try:
489  await self._api_api.unsubscribe(sub_type=SubType.long_poll)
490  except ReolinkError as err:
491  _LOGGER.error(
492  "Reolink error while unsubscribing from host %s:%s: %s",
493  self._api_api.host,
494  self._api_api.port,
495  err,
496  )
497 
498  async def stop(self, *_: Any) -> None:
499  """Disconnect the API."""
500  if self._cancel_poll_cancel_poll is not None:
501  self._cancel_poll_cancel_poll()
502  self._cancel_poll_cancel_poll = None
503  if self._cancel_tcp_push_check_cancel_tcp_push_check is not None:
504  self._cancel_tcp_push_check_cancel_tcp_push_check()
505  self._cancel_tcp_push_check_cancel_tcp_push_check = None
506  if self._cancel_onvif_check_cancel_onvif_check is not None:
507  self._cancel_onvif_check_cancel_onvif_check()
508  self._cancel_onvif_check_cancel_onvif_check = None
509  if self._cancel_long_poll_check_cancel_long_poll_check is not None:
510  self._cancel_long_poll_check_cancel_long_poll_check()
511  self._cancel_long_poll_check_cancel_long_poll_check = None
512  await self._async_stop_long_polling_async_stop_long_polling()
513  self.unregister_webhookunregister_webhook()
514  await self.disconnectdisconnect()
515 
516  async def subscribe(self) -> None:
517  """Subscribe to motion events and register the webhook as a callback."""
518  if self.webhook_idwebhook_id is None:
519  self.register_webhookregister_webhook()
520 
521  if self._api_api.subscribed(SubType.push):
522  _LOGGER.debug(
523  "Host %s: is already subscribed to webhook %s",
524  self._api_api.host,
525  self._webhook_url_webhook_url,
526  )
527  return
528 
529  await self._api_api.subscribe(self._webhook_url_webhook_url)
530 
531  _LOGGER.debug(
532  "Host %s: subscribed successfully to webhook %s",
533  self._api_api.host,
534  self._webhook_url_webhook_url,
535  )
536 
537  async def renew(self) -> None:
538  """Renew the subscription of motion events (lease time is 15 minutes)."""
539  await self._api_api.baichuan.check_subscribe_events()
540 
541  if self._api_api.baichuan.events_active and self._api_api.subscribed(SubType.push):
542  # TCP push active, unsubscribe from ONVIF push because not needed
543  self.unregister_webhookunregister_webhook()
544  await self._api_api.unsubscribe()
545 
546  try:
547  if self._onvif_push_supported_onvif_push_supported and not self._api_api.baichuan.events_active:
548  await self._renew_renew(SubType.push)
549 
550  if self._onvif_long_poll_supported_onvif_long_poll_supported and self._long_poll_task_long_poll_task is not None:
551  if not self._api_api.subscribed(SubType.long_poll):
552  _LOGGER.debug("restarting long polling task")
553  # To prevent 5 minute request timeout
554  await self._async_stop_long_polling_async_stop_long_polling()
555  await self._async_start_long_polling_async_start_long_polling()
556  else:
557  await self._renew_renew(SubType.long_poll)
558  except SubscriptionError as err:
559  if not self._lost_subscription_lost_subscription:
560  self._lost_subscription_lost_subscription = True
561  _LOGGER.error(
562  "Reolink %s event subscription lost: %s",
563  self._api_api.nvr_name,
564  err,
565  )
566  else:
567  self._lost_subscription_lost_subscription = False
568 
569  async def _renew(self, sub_type: Literal[SubType.push, SubType.long_poll]) -> None:
570  """Execute the renew of the subscription."""
571  if not self._api_api.subscribed(sub_type):
572  _LOGGER.debug(
573  "Host %s: requested to renew a non-existing Reolink %s subscription, "
574  "trying to subscribe from scratch",
575  self._api_api.host,
576  sub_type,
577  )
578  if sub_type == SubType.push:
579  await self.subscribesubscribe()
580  return
581 
582  timer = self._api_api.renewtimer(sub_type)
583  _LOGGER.debug(
584  "Host %s:%s should renew %s subscription in: %i seconds",
585  self._api_api.host,
586  self._api_api.port,
587  sub_type,
588  timer,
589  )
590  if timer > SUBSCRIPTION_RENEW_THRESHOLD:
591  return
592 
593  if timer > 0:
594  try:
595  await self._api_api.renew(sub_type)
596  except SubscriptionError as err:
597  _LOGGER.debug(
598  "Host %s: error renewing Reolink %s subscription, "
599  "trying to subscribe again: %s",
600  self._api_api.host,
601  sub_type,
602  err,
603  )
604  else:
605  _LOGGER.debug(
606  "Host %s successfully renewed Reolink %s subscription",
607  self._api_api.host,
608  sub_type,
609  )
610  return
611 
612  await self._api_api.subscribe(self._webhook_url_webhook_url, sub_type)
613 
614  _LOGGER.debug(
615  "Host %s: Reolink %s re-subscription successful after it was expired",
616  self._api_api.host,
617  sub_type,
618  )
619 
620  def register_webhook(self) -> None:
621  """Register the webhook for motion events."""
622  self.webhook_idwebhook_id = (
623  f"{DOMAIN}_{self.unique_id.replace(':', '')}_{webhook.async_generate_id()}"
624  )
625  event_id = self.webhook_idwebhook_id
626 
627  webhook.async_register(
628  self._hass, DOMAIN, event_id, event_id, self.handle_webhookhandle_webhook
629  )
630 
631  try:
632  self._base_url_base_url = get_url(self._hass, prefer_external=False)
633  except NoURLAvailableError:
634  try:
635  self._base_url_base_url = get_url(self._hass, prefer_external=True)
636  except NoURLAvailableError as err:
637  self.unregister_webhookunregister_webhook()
639  f"Error registering URL for webhook {event_id}: "
640  "HomeAssistant URL is not available"
641  ) from err
642 
643  webhook_path = webhook.async_generate_path(event_id)
644  self._webhook_url_webhook_url = f"{self._base_url}{webhook_path}"
645 
646  _LOGGER.debug("Registered webhook: %s", event_id)
647 
648  def unregister_webhook(self) -> None:
649  """Unregister the webhook for motion events."""
650  if self.webhook_idwebhook_id is None:
651  return
652  _LOGGER.debug("Unregistering webhook %s", self.webhook_idwebhook_id)
653  webhook.async_unregister(self._hass, self.webhook_idwebhook_id)
654  self.webhook_idwebhook_id = None
655 
656  async def _async_long_polling(self, *_: Any) -> None:
657  """Use ONVIF long polling to immediately receive events."""
658  # This task will be cancelled once _async_stop_long_polling is called
659  while True:
660  if self._api_api.baichuan.events_active or self._webhook_reachable_webhook_reachable:
661  # TCP push or ONVIF push working, stop long polling
662  self._long_poll_task_long_poll_task = None
663  await self._async_stop_long_polling_async_stop_long_polling()
664  return
665 
666  try:
667  channels = await self._api_api.pull_point_request()
668  except ReolinkError as ex:
669  if not self._long_poll_error_long_poll_error:
670  _LOGGER.error("Error while requesting ONVIF pull point: %s", ex)
671  await self._api_api.unsubscribe(sub_type=SubType.long_poll)
672  self._long_poll_error_long_poll_error = True
673  await asyncio.sleep(LONG_POLL_ERROR_COOLDOWN)
674  continue
675  except Exception:
676  _LOGGER.exception(
677  "Unexpected exception while requesting ONVIF pull point"
678  )
679  await self._api_api.unsubscribe(sub_type=SubType.long_poll)
680  raise
681 
682  self._long_poll_error_long_poll_error = False
683 
684  if not self._long_poll_received_long_poll_received:
685  self._long_poll_received_long_poll_received = True
686  ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
687 
688  self._signal_write_ha_state_signal_write_ha_state(channels)
689 
690  # Cooldown to prevent CPU over usage on camera freezes
691  await asyncio.sleep(LONG_POLL_COOLDOWN)
692 
693  async def _async_poll_all_motion(self, *_: Any) -> None:
694  """Poll motion and AI states until the first ONVIF push is received."""
695  if (
696  self._api_api.baichuan.events_active
697  or self._webhook_reachable_webhook_reachable
698  or self._long_poll_received_long_poll_received
699  ):
700  # TCP push, ONVIF push or long polling is working, stop fast polling
701  self._cancel_poll_cancel_poll = None
702  return
703 
704  try:
705  if self._api_api.session_active:
706  await self._api_api.get_motion_state_all_ch()
707  except ReolinkError as err:
708  if not self._fast_poll_error_fast_poll_error:
709  _LOGGER.error(
710  "Reolink error while polling motion state for host %s:%s: %s",
711  self._api_api.host,
712  self._api_api.port,
713  err,
714  )
715  self._fast_poll_error_fast_poll_error = True
716  else:
717  if self._api_api.session_active:
718  self._fast_poll_error_fast_poll_error = False
719  finally:
720  # schedule next poll
721  if not self._hass.is_stopping:
722  self._cancel_poll_cancel_poll = async_call_later(
723  self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job_poll_job
724  )
725 
726  self._signal_write_ha_state_signal_write_ha_state()
727 
728  async def handle_webhook(
729  self, hass: HomeAssistant, webhook_id: str, request: Request
730  ) -> None:
731  """Read the incoming webhook from Reolink for inbound messages and schedule processing."""
732  _LOGGER.debug("Webhook '%s' called", webhook_id)
733  data: bytes | None = None
734  try:
735  data = await request.read()
736  if not data:
737  _LOGGER.debug(
738  "Webhook '%s' triggered with unknown payload: %s", webhook_id, data
739  )
740  except ConnectionResetError:
741  _LOGGER.debug(
742  "Webhook '%s' called, but lost connection before reading message "
743  "(ConnectionResetError), issuing poll",
744  webhook_id,
745  )
746  return
747  except aiohttp.ClientResponseError:
748  _LOGGER.debug(
749  "Webhook '%s' called, but could not read the message, issuing poll",
750  webhook_id,
751  )
752  return
753  except asyncio.CancelledError:
754  _LOGGER.debug(
755  "Webhook '%s' called, but lost connection before reading message "
756  "(CancelledError), issuing poll",
757  webhook_id,
758  )
759  raise
760  finally:
761  # We want handle_webhook to return as soon as possible
762  # so we process the data in the background, this also shields from cancellation
763  hass.async_create_background_task(
764  self._process_webhook_data_process_webhook_data(hass, webhook_id, data),
765  "Process Reolink webhook",
766  )
767 
769  self, hass: HomeAssistant, webhook_id: str, data: bytes | None
770  ) -> None:
771  """Process the data from the Reolink webhook."""
772  # This task is executed in the background so we need to catch exceptions
773  # and log them
774  if not self._webhook_reachable_webhook_reachable:
775  self._webhook_reachable_webhook_reachable = True
776  ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
777 
778  try:
779  if not data:
780  if not await self._api_api.get_motion_state_all_ch():
781  _LOGGER.error(
782  "Could not poll motion state after losing connection during receiving ONVIF event"
783  )
784  return
785  self._signal_write_ha_state_signal_write_ha_state()
786  return
787 
788  message = data.decode("utf-8")
789  channels = await self._api_api.ONVIF_event_callback(message)
790  except Exception:
791  _LOGGER.exception(
792  "Error processing ONVIF event for Reolink %s", self._api_api.nvr_name
793  )
794  return
795 
796  self._signal_write_ha_state_signal_write_ha_state(channels)
797 
798  def _signal_write_ha_state(self, channels: list[int] | None = None) -> None:
799  """Update the binary sensors with async_write_ha_state."""
800  if channels is None:
801  async_dispatcher_send(self._hass, f"{self.unique_id}_all", {})
802  return
803 
804  for channel in channels:
805  async_dispatcher_send(self._hass, f"{self.unique_id}_{channel}", {})
806 
807  @property
808  def event_connection(self) -> str:
809  """Type of connection to receive events."""
810  if self._api_api.baichuan.events_active:
811  return "TCP push"
812  if self._webhook_reachable_webhook_reachable:
813  return "ONVIF push"
814  if self._long_poll_received_long_poll_received:
815  return "ONVIF long polling"
816  return "Fast polling"
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
bool time(HomeAssistant hass, dt_time|str|None before=None, dt_time|str|None after=None, str|Container[str]|None weekday=None)
Definition: condition.py:802
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
Definition: network.py:131