1 """Module which encapsulates the NVR/camera API and subscription."""
3 from __future__
import annotations
6 from collections
import defaultdict
7 from collections.abc
import Mapping
10 from typing
import Any, Literal
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
35 from .const
import CONF_USE_HTTPS, DOMAIN
36 from .exceptions
import (
38 ReolinkSetupException,
39 ReolinkWebhookException,
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
54 BATTERY_WAKE_UPDATE_INTERVAL = 3600
56 _LOGGER = logging.getLogger(__name__)
60 """The implementation of the Reolink Host class."""
65 config: Mapping[str, Any],
66 options: Mapping[str, Any],
68 """Initialize Reolink Host. Could be either NVR, or Camera."""
69 self._hass: HomeAssistant = hass
72 def get_aiohttp_session() -> aiohttp.ClientSession:
73 """Return the HA aiohttp session."""
77 ssl_cipher=SSLCipherList.INSECURE,
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,
92 self.update_cmd: defaultdict[str, defaultdict[int |
None, int]] = defaultdict(
93 lambda: defaultdict(int)
95 self.firmware_ch_list: list[int |
None] = []
97 self.starting: bool =
True
98 self.credential_errors: int = 0
108 self.
_cancel_poll_cancel_poll: CALLBACK_TYPE |
None =
None
119 """Register the command to update the state."""
120 self.update_cmd[cmd][channel] += 1
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]
133 """Create the unique ID, base for all entities."""
138 """Return the API object."""
142 """Connect to Reolink host."""
143 if not self.
_api_api.valid_password():
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}"
150 await self.
_api_api.get_host_data()
152 if self.
_api_api.mac_address
is None:
155 if not self.
_api_api.is_admin:
157 f
"User '{self._api.username}' has authorization level "
158 f
"'{self._api.user_level}', only admin users can change camera settings"
169 if not self.
_api_api.rtsp_enabled:
171 "RTSP is disabled on %s, trying to enable it", self.
_api_api.nvr_name
175 if not self.
_api_api.onvif_enabled
and onvif_supported:
177 "ONVIF is disabled on %s, trying to enable it", self.
_api_api.nvr_name
181 if not self.
_api_api.rtmp_enabled
and self.
_api_api.protocol ==
"rtmp":
183 "RTMP is disabled on %s, trying to enable it", self.
_api_api.nvr_name
187 if enable_onvif
or enable_rtmp
or enable_rtsp:
189 await self.
_api_api.set_net_port(
190 enable_onvif=enable_onvif,
191 enable_rtmp=enable_rtmp,
192 enable_rtsp=enable_rtsp,
205 ir.async_create_issue(
210 severity=ir.IssueSeverity.WARNING,
211 translation_key=
"enable_port",
212 translation_placeholders={
213 "name": self.
_api_api.nvr_name,
215 "info_link":
"https://support.reolink.com/hc/en-us/articles/900004435763-How-to-Set-up-Reolink-Ports-Settings-via-Reolink-Client-New-Client-",
219 ir.async_delete_issue(self._hass, DOMAIN,
"enable_port")
227 await self.
_api_api.baichuan.subscribe_events()
235 ch_list: list[int |
None] = [
None]
236 if self.
_api_api.is_nvr:
237 ch_list.extend(self.
_api_api.channels)
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(
247 f
"firmware_update_{key}",
249 severity=ir.IssueSeverity.WARNING,
250 translation_key=
"firmware_update",
251 translation_placeholders={
252 "required_firmware": self.
_api_api.camera_sw_version_required(
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/",
263 ir.async_delete_issue(self._hass, DOMAIN, f
"firmware_update_{key}")
266 """Check the TCP push subscription."""
267 if self.
_api_api.baichuan.events_active:
268 ir.async_delete_issue(self._hass, DOMAIN,
"webhook_url")
273 "Reolink %s, did not receive initial TCP push event after %i seconds",
274 self.
_api_api.nvr_name,
275 FIRST_TCP_PUSH_TIMEOUT,
284 await self.
_api_api.unsubscribe()
288 "Waiting for initial ONVIF state on webhook '%s'",
293 "Camera model %s most likely does not push its initial state"
294 " upon ONVIF subscription, do not check",
304 "Camera model %s does not support ONVIF push, using ONVIF long polling instead",
309 except NotSupportedError:
311 "Camera model %s does not support ONVIF long polling, using fast polling instead",
315 await self.
_api_api.unsubscribe()
320 FIRST_ONVIF_LONG_POLL_TIMEOUT,
327 """Check the ONVIF subscription."""
329 ir.async_delete_issue(self._hass, DOMAIN,
"webhook_url")
334 "Did not receive initial ONVIF state on webhook '%s' after %i seconds",
348 """Check if ONVIF long polling is working."""
351 "Did not receive state through ONVIF long polling after %i seconds",
352 FIRST_ONVIF_LONG_POLL_TIMEOUT,
354 ir.async_create_issue(
359 severity=ir.IssueSeverity.WARNING,
360 translation_key=
"webhook_url",
361 translation_placeholders={
362 "name": self.
_api_api.nvr_name,
364 "network_link":
"https://my.home-assistant.io/redirect/network/",
368 if self.
_base_url_base_url.startswith(
"https"):
369 ir.async_create_issue(
374 severity=ir.IssueSeverity.WARNING,
375 translation_key=
"https_webhook",
376 translation_placeholders={
378 "network_link":
"https://my.home-assistant.io/redirect/network/",
382 ir.async_delete_issue(self._hass, DOMAIN,
"https_webhook")
384 if self._hass.config.api
is not None and self._hass.config.api.use_ssl:
385 ir.async_create_issue(
390 severity=ir.IssueSeverity.WARNING,
391 translation_key=
"ssl",
392 translation_placeholders={
393 "ssl_link":
"https://www.home-assistant.io/integrations/http/#ssl_certificate",
395 "network_link":
"https://my.home-assistant.io/redirect/network/",
396 "nginx_link":
"https://github.com/home-assistant/addons/tree/master/nginx_proxy",
400 ir.async_delete_issue(self._hass, DOMAIN,
"ssl")
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")
412 """Call the API of the camera device to update the internal states."""
414 if time() - self.
last_wakelast_wake > BATTERY_WAKE_UPDATE_INTERVAL:
419 await self.
_api_api.get_states(cmd_list=self.update_cmd, wake=wake)
422 """Disconnect from the API, so the connection will be released."""
424 await self.
_api_api.baichuan.unsubscribe_events()
425 except ReolinkError
as err:
427 "Reolink error while unsubscribing Baichuan from host %s:%s: %s",
434 await self.
_api_api.unsubscribe()
435 except ReolinkError
as err:
437 "Reolink error while unsubscribing from host %s:%s: %s",
444 await self.
_api_api.logout()
445 except ReolinkError
as err:
447 "Reolink error while logging out for host %s:%s: %s",
454 """Start ONVIF long polling task."""
458 except NotSupportedError
as err:
465 "Reolink %s event long polling subscription lost: %s",
466 self.
_api_api.nvr_name,
469 except ReolinkError
as err:
474 "Reolink %s event long polling subscription lost: %s",
475 self.
_api_api.nvr_name,
483 """Stop ONVIF long polling task."""
489 await self.
_api_api.unsubscribe(sub_type=SubType.long_poll)
490 except ReolinkError
as err:
492 "Reolink error while unsubscribing from host %s:%s: %s",
498 async
def stop(self, *_: Any) ->
None:
499 """Disconnect the API."""
517 """Subscribe to motion events and register the webhook as a callback."""
521 if self.
_api_api.subscribed(SubType.push):
523 "Host %s: is already subscribed to webhook %s",
532 "Host %s: subscribed successfully to webhook %s",
538 """Renew the subscription of motion events (lease time is 15 minutes)."""
539 await self.
_api_api.baichuan.check_subscribe_events()
541 if self.
_api_api.baichuan.events_active
and self.
_api_api.subscribed(SubType.push):
544 await self.
_api_api.unsubscribe()
548 await self.
_renew_renew(SubType.push)
551 if not self.
_api_api.subscribed(SubType.long_poll):
552 _LOGGER.debug(
"restarting long polling task")
557 await self.
_renew_renew(SubType.long_poll)
558 except SubscriptionError
as err:
562 "Reolink %s event subscription lost: %s",
563 self.
_api_api.nvr_name,
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):
573 "Host %s: requested to renew a non-existing Reolink %s subscription, "
574 "trying to subscribe from scratch",
578 if sub_type == SubType.push:
582 timer = self.
_api_api.renewtimer(sub_type)
584 "Host %s:%s should renew %s subscription in: %i seconds",
590 if timer > SUBSCRIPTION_RENEW_THRESHOLD:
596 except SubscriptionError
as err:
598 "Host %s: error renewing Reolink %s subscription, "
599 "trying to subscribe again: %s",
606 "Host %s successfully renewed Reolink %s subscription",
615 "Host %s: Reolink %s re-subscription successful after it was expired",
621 """Register the webhook for motion events."""
623 f
"{DOMAIN}_{self.unique_id.replace(':', '')}_{webhook.async_generate_id()}"
627 webhook.async_register(
628 self._hass, DOMAIN, event_id, event_id, self.
handle_webhookhandle_webhook
633 except NoURLAvailableError:
636 except NoURLAvailableError
as err:
639 f
"Error registering URL for webhook {event_id}: "
640 "HomeAssistant URL is not available"
643 webhook_path = webhook.async_generate_path(event_id)
646 _LOGGER.debug(
"Registered webhook: %s", event_id)
649 """Unregister the webhook for motion events."""
652 _LOGGER.debug(
"Unregistering webhook %s", self.
webhook_idwebhook_id)
653 webhook.async_unregister(self._hass, self.
webhook_idwebhook_id)
657 """Use ONVIF long polling to immediately receive events."""
667 channels = await self.
_api_api.pull_point_request()
668 except ReolinkError
as ex:
670 _LOGGER.error(
"Error while requesting ONVIF pull point: %s", ex)
671 await self.
_api_api.unsubscribe(sub_type=SubType.long_poll)
673 await asyncio.sleep(LONG_POLL_ERROR_COOLDOWN)
677 "Unexpected exception while requesting ONVIF pull point"
679 await self.
_api_api.unsubscribe(sub_type=SubType.long_poll)
686 ir.async_delete_issue(self._hass, DOMAIN,
"webhook_url")
691 await asyncio.sleep(LONG_POLL_COOLDOWN)
694 """Poll motion and AI states until the first ONVIF push is received."""
696 self.
_api_api.baichuan.events_active
705 if self.
_api_api.session_active:
706 await self.
_api_api.get_motion_state_all_ch()
707 except ReolinkError
as err:
710 "Reolink error while polling motion state for host %s:%s: %s",
717 if self.
_api_api.session_active:
721 if not self._hass.is_stopping:
723 self._hass, POLL_INTERVAL_NO_PUSH, self.
_poll_job_poll_job
729 self, hass: HomeAssistant, webhook_id: str, request: Request
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
735 data = await request.read()
738 "Webhook '%s' triggered with unknown payload: %s", webhook_id, data
740 except ConnectionResetError:
742 "Webhook '%s' called, but lost connection before reading message "
743 "(ConnectionResetError), issuing poll",
747 except aiohttp.ClientResponseError:
749 "Webhook '%s' called, but could not read the message, issuing poll",
753 except asyncio.CancelledError:
755 "Webhook '%s' called, but lost connection before reading message "
756 "(CancelledError), issuing poll",
763 hass.async_create_background_task(
765 "Process Reolink webhook",
769 self, hass: HomeAssistant, webhook_id: str, data: bytes |
None
771 """Process the data from the Reolink webhook."""
776 ir.async_delete_issue(self._hass, DOMAIN,
"webhook_url")
780 if not await self.
_api_api.get_motion_state_all_ch():
782 "Could not poll motion state after losing connection during receiving ONVIF event"
788 message = data.decode(
"utf-8")
789 channels = await self.
_api_api.ONVIF_event_callback(message)
792 "Error processing ONVIF event for Reolink %s", self.
_api_api.nvr_name
799 """Update the binary sensors with async_write_ha_state."""
804 for channel
in channels:
809 """Type of connection to receive events."""
810 if self.
_api_api.baichuan.events_active:
815 return "ONVIF long polling"
816 return "Fast polling"
None _async_poll_all_motion(self, *Any _)
None _async_check_onvif_long_poll(self, *Any _)
None register_webhook(self)
None __init__(self, HomeAssistant hass, Mapping[str, Any] config, Mapping[str, Any] options)
None async_register_update_cmd(self, str cmd, int|None channel=None)
None _async_stop_long_polling(self)
None _async_long_polling(self, *Any _)
None _renew(self, Literal[SubType.push, SubType.long_poll] sub_type)
_onvif_long_poll_supported
None _async_check_tcp_push(self, *Any _)
None handle_webhook(self, HomeAssistant hass, str webhook_id, Request request)
str event_connection(self)
None _process_webhook_data(self, HomeAssistant hass, str webhook_id, bytes|None data)
None unregister_webhook(self)
None _signal_write_ha_state(self, list[int]|None channels=None)
None _async_start_long_polling(self, bool initial=False)
None _async_check_onvif(self, *Any _)
None async_unregister_update_cmd(self, str cmd, int|None channel=None)
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)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
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)
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)