Fix onvif cameras that cannot parse relative time (#92711)

* Fix onvif cameras that cannot parse relative time

The spec requires that the camera can parse relative or absolute timestamps
However there are many cameras that cannot parse time correctly.

Much of the event code has been offloaded to the library and
support to determine if the camera has broken time and switch
to absolute timestamps is now built into the library

* adjust verison

* fixes

* bump

* bump

* bump

* more fixes

* preen

* fix resume

* one more fix

* fix race in webhook setup

* bump to 3.1.3 which has more fixes for broken camera firmwares

* bump 3.1.4 for more fixes

* fix

* fix comment

* bump

* fix url limit

* bump

* more fixes

* old hik uses -s
This commit is contained in:
J. Nick Koston 2023-05-14 15:08:39 -05:00 committed by GitHub
parent d5a0824924
commit ef887e529f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 199 additions and 406 deletions

View File

@ -5,6 +5,7 @@ import logging
from httpx import RequestError from httpx import RequestError
from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError
from onvif.util import is_auth_error, stringify_onvif_error
from zeep.exceptions import Fault, TransportError from zeep.exceptions import Fault, TransportError
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS
@ -21,7 +22,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN
from .device import ONVIFDevice from .device import ONVIFDevice
from .util import is_auth_error, stringify_onvif_error
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)

View File

@ -6,6 +6,7 @@ from pprint import pformat
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from onvif.util import is_auth_error, stringify_onvif_error
import voluptuous as vol import voluptuous as vol
from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery
from wsdiscovery.scope import Scope from wsdiscovery.scope import Scope
@ -40,7 +41,6 @@ from .const import (
LOGGER, LOGGER,
) )
from .device import get_device from .device import get_device
from .util import is_auth_error, stringify_onvif_error
CONF_MANUAL_INPUT = "Manually configure ONVIF device" CONF_MANUAL_INPUT = "Manually configure ONVIF device"

View File

@ -195,7 +195,9 @@ class ONVIFDevice:
await device_mgmt.SetSystemDateAndTime(dt_param) await device_mgmt.SetSystemDateAndTime(dt_param)
LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) LOGGER.debug("%s: SetSystemDateAndTime: success", self.name)
return return
except Fault: # Some cameras don't support setting the timezone and will throw an IndexError
# if we try to set it. If we get an error, try again without the timezone.
except (IndexError, Fault):
if idx == timezone_max_idx: if idx == timezone_max_idx:
raise raise
@ -280,7 +282,7 @@ class ONVIFDevice:
# Set Date and Time ourselves if Date and Time is set manually in the camera. # Set Date and Time ourselves if Date and Time is set manually in the camera.
try: try:
await self.async_manually_set_date_and_time() await self.async_manually_set_date_and_time()
except (RequestError, TransportError): except (RequestError, TransportError, IndexError, Fault):
LOGGER.warning("%s: Could not sync date/time on this camera", self.name) LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
async def async_get_device_info(self) -> DeviceInfo: async def async_get_device_info(self) -> DeviceInfo:

View File

@ -3,32 +3,30 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress
import datetime as dt import datetime as dt
from aiohttp.web import Request from aiohttp.web import Request
from httpx import RemoteProtocolError, RequestError, TransportError from httpx import RemoteProtocolError, RequestError, TransportError
from onvif import ONVIFCamera, ONVIFService from onvif import ONVIFCamera, ONVIFService
from onvif.client import NotificationManager, retry_connection_error from onvif.client import (
NotificationManager,
PullPointManager as ONVIFPullPointManager,
retry_connection_error,
)
from onvif.exceptions import ONVIFError from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
from zeep.exceptions import Fault, ValidationError, XMLParseError from zeep.exceptions import Fault, ValidationError, XMLParseError
from homeassistant.components import webhook from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import ( from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
CALLBACK_TYPE, from homeassistant.helpers.device_registry import format_mac
CoreState,
HassJob,
HomeAssistant,
callback,
)
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.network import NoURLAvailableError, get_url
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .models import Event, PullPointManagerState, WebHookManagerState from .models import Event, PullPointManagerState, WebHookManagerState
from .parsers import PARSERS from .parsers import PARSERS
from .util import stringify_onvif_error
# Topics in this list are ignored because we do not want to create # Topics in this list are ignored because we do not want to create
# entities for them. # entities for them.
@ -51,11 +49,6 @@ RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
# #
SUBSCRIPTION_TIME = dt.timedelta(minutes=10) SUBSCRIPTION_TIME = dt.timedelta(minutes=10)
# SUBSCRIPTION_RELATIVE_TIME uses a relative time since the time on the camera
# is not reliable. We use 600 seconds (10 minutes) since some cameras cannot
# parse time in the format "PT10M" (10 minutes).
SUBSCRIPTION_RELATIVE_TIME = "PT600S"
# SUBSCRIPTION_RENEW_INTERVAL Must be less than the # SUBSCRIPTION_RENEW_INTERVAL Must be less than the
# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds # overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds
# #
@ -106,18 +99,13 @@ class EventManager:
or self.pullpoint_manager.state == PullPointManagerState.STARTED or self.pullpoint_manager.state == PullPointManagerState.STARTED
) )
@property
def has_listeners(self) -> bool:
"""Return if there are listeners."""
return bool(self._listeners)
@callback @callback
def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]:
"""Listen for data updates.""" """Listen for data updates."""
# This is the first listener, set up polling. # We always have to listen for events or we will never
if not self._listeners: # know which sensors to create. In practice we always have
self.pullpoint_manager.async_schedule_pull_messages() # a listener anyways since binary_sensor and sensor will
# create a listener when they are created.
self._listeners.append(update_callback) self._listeners.append(update_callback)
@callback @callback
@ -133,9 +121,6 @@ class EventManager:
if update_callback in self._listeners: if update_callback in self._listeners:
self._listeners.remove(update_callback) self._listeners.remove(update_callback)
if not self._listeners:
self.pullpoint_manager.async_cancel_pull_messages()
async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool: async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool:
"""Start polling events.""" """Start polling events."""
# Always start pull point first, since it will populate the event list # Always start pull point first, since it will populate the event list
@ -255,22 +240,15 @@ class PullPointManager:
self._hass = event_manager.hass self._hass = event_manager.hass
self._name = event_manager.name self._name = event_manager.name
self._pullpoint_subscription: ONVIFService = None
self._pullpoint_service: ONVIFService = None self._pullpoint_service: ONVIFService = None
self._pull_lock: asyncio.Lock = asyncio.Lock() self._pullpoint_manager: ONVIFPullPointManager | None = None
self._cancel_pull_messages: CALLBACK_TYPE | None = None self._cancel_pull_messages: CALLBACK_TYPE | None = None
self._cancel_pullpoint_renew: CALLBACK_TYPE | None = None
self._renew_lock: asyncio.Lock = asyncio.Lock()
self._renew_or_restart_job = HassJob(
self._async_renew_or_restart_pullpoint,
f"{self._name}: renew or restart pullpoint",
)
self._pull_messages_job = HassJob( self._pull_messages_job = HassJob(
self._async_background_pull_messages, self._async_background_pull_messages_or_reschedule,
f"{self._name}: pull messages", f"{self._name}: pull messages",
) )
self._pull_messages_task: asyncio.Task[None] | None = None
async def async_start(self) -> bool: async def async_start(self) -> bool:
"""Start pullpoint subscription.""" """Start pullpoint subscription."""
@ -282,6 +260,7 @@ class PullPointManager:
self.state = PullPointManagerState.FAILED self.state = PullPointManagerState.FAILED
return False return False
self.state = PullPointManagerState.STARTED self.state = PullPointManagerState.STARTED
self.async_schedule_pull_messages()
return True return True
@callback @callback
@ -291,8 +270,9 @@ class PullPointManager:
self.state = PullPointManagerState.PAUSED self.state = PullPointManagerState.PAUSED
# Cancel the renew job so we don't renew the subscription # Cancel the renew job so we don't renew the subscription
# and stop pulling messages. # and stop pulling messages.
self._async_cancel_pullpoint_renew()
self.async_cancel_pull_messages() self.async_cancel_pull_messages()
if self._pullpoint_manager:
self._pullpoint_manager.pause()
# We do not unsubscribe from the pullpoint subscription and instead # We do not unsubscribe from the pullpoint subscription and instead
# let the subscription expire since some cameras will terminate all # let the subscription expire since some cameras will terminate all
# subscriptions if we unsubscribe which will break the webhook. # subscriptions if we unsubscribe which will break the webhook.
@ -302,17 +282,150 @@ class PullPointManager:
"""Resume pullpoint subscription.""" """Resume pullpoint subscription."""
LOGGER.debug("%s: Resuming PullPoint manager", self._name) LOGGER.debug("%s: Resuming PullPoint manager", self._name)
self.state = PullPointManagerState.STARTED self.state = PullPointManagerState.STARTED
self.async_schedule_pullpoint_renew(0.0) if self._pullpoint_manager:
self._pullpoint_manager.resume()
self.async_schedule_pull_messages()
@callback async def async_stop(self) -> None:
def async_schedule_pullpoint_renew(self, delay: float) -> None: """Unsubscribe from PullPoint and cancel callbacks."""
"""Schedule PullPoint subscription renewal.""" self.state = PullPointManagerState.STOPPED
self._async_cancel_pullpoint_renew() await self._async_cancel_and_unsubscribe()
self._cancel_pullpoint_renew = async_call_later(
self._hass, async def _async_start_pullpoint(self) -> bool:
delay, """Start pullpoint subscription."""
self._renew_or_restart_job, try:
await self._async_create_pullpoint_subscription()
except CREATE_ERRORS as err:
LOGGER.debug(
"%s: Device does not support PullPoint service or has too many subscriptions: %s",
self._name,
stringify_onvif_error(err),
)
return False
return True
async def _async_cancel_and_unsubscribe(self) -> None:
"""Cancel and unsubscribe from PullPoint."""
self.async_cancel_pull_messages()
if self._pull_messages_task:
self._pull_messages_task.cancel()
await self._async_unsubscribe_pullpoint()
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
async def _async_create_pullpoint_subscription(self) -> None:
"""Create pullpoint subscription."""
self._pullpoint_manager = await self._device.create_pullpoint_manager(
SUBSCRIPTION_TIME, self._event_manager.async_mark_events_stale
) )
self._pullpoint_service = self._pullpoint_manager.get_service()
await self._pullpoint_manager.set_synchronization_point()
async def _async_unsubscribe_pullpoint(self) -> None:
"""Unsubscribe the pullpoint subscription."""
if not self._pullpoint_manager or self._pullpoint_manager.closed:
return
LOGGER.debug("%s: Unsubscribing from PullPoint", self._name)
try:
await self._pullpoint_manager.shutdown()
except UNSUBSCRIBE_ERRORS as err:
LOGGER.debug(
(
"%s: Failed to unsubscribe PullPoint subscription;"
" This is normal if the device restarted: %s"
),
self._name,
stringify_onvif_error(err),
)
self._pullpoint_manager = None
async def _async_pull_messages(self) -> None:
"""Pull messages from device."""
if self._pullpoint_manager is None:
return
assert self._pullpoint_service is not None, "PullPoint service does not exist"
LOGGER.debug(
"%s: Pulling PullPoint messages timeout=%s limit=%s",
self._name,
PULLPOINT_POLL_TIME,
PULLPOINT_MESSAGE_LIMIT,
)
next_pull_delay = None
response = None
try:
if self._hass.is_running:
response = await self._pullpoint_service.PullMessages(
{
"MessageLimit": PULLPOINT_MESSAGE_LIMIT,
"Timeout": PULLPOINT_POLL_TIME,
}
)
else:
LOGGER.debug(
"%s: PullPoint skipped because Home Assistant is not running yet",
self._name,
)
except RemoteProtocolError as err:
# Either a shutdown event or the camera closed the connection. Because
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
# to close the connection at any time, we treat this as a normal. Some
# cameras may close the connection if there are no messages to pull.
LOGGER.debug(
"%s: PullPoint subscription encountered a remote protocol error "
"(this is normal for some cameras): %s",
self._name,
stringify_onvif_error(err),
)
except Fault as err:
# Device may not support subscriptions so log at debug level
# when we get an XMLParseError
LOGGER.debug(
"%s: Failed to fetch PullPoint subscription messages: %s",
self._name,
stringify_onvif_error(err),
)
# Treat errors as if the camera restarted. Assume that the pullpoint
# subscription is no longer valid.
self._pullpoint_manager.resume()
except (XMLParseError, RequestError, TimeoutError, TransportError) as err:
LOGGER.debug(
"%s: PullPoint subscription encountered an unexpected error and will be retried "
"(this is normal for some cameras): %s",
self._name,
stringify_onvif_error(err),
)
# Avoid renewing the subscription too often since it causes problems
# for some cameras, mainly the Tapo ones.
next_pull_delay = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
finally:
self.async_schedule_pull_messages(next_pull_delay)
if self.state != PullPointManagerState.STARTED:
# If the webhook became started working during the long poll,
# and we got paused, our data is stale and we should not process it.
LOGGER.debug(
"%s: PullPoint state is %s (likely due to working webhook), skipping PullPoint messages",
self._name,
self.state,
)
return
if not response:
return
# Parse response
event_manager = self._event_manager
if (notification_message := response.NotificationMessage) and (
number_of_events := len(notification_message)
):
LOGGER.debug(
"%s: continuous PullMessages: %s event(s)",
self._name,
number_of_events,
)
await event_manager.async_parse_messages(notification_message)
event_manager.async_callback_listeners()
else:
LOGGER.debug("%s: continuous PullMessages: no events", self._name)
@callback @callback
def async_cancel_pull_messages(self) -> None: def async_cancel_pull_messages(self) -> None:
@ -338,269 +451,23 @@ class PullPointManager:
self._hass, when, self._pull_messages_job self._hass, when, self._pull_messages_job
) )
async def async_stop(self) -> None: @callback
"""Unsubscribe from PullPoint and cancel callbacks.""" def _async_background_pull_messages_or_reschedule(
self.state = PullPointManagerState.STOPPED self, _now: dt.datetime | None = None
await self._async_cancel_and_unsubscribe()
async def _async_start_pullpoint(self) -> bool:
"""Start pullpoint subscription."""
try:
started = await self._async_create_pullpoint_subscription()
except CREATE_ERRORS as err:
LOGGER.debug(
"%s: Device does not support PullPoint service or has too many subscriptions: %s",
self._name,
stringify_onvif_error(err),
)
return False
if started:
self.async_schedule_pullpoint_renew(SUBSCRIPTION_RENEW_INTERVAL)
return started
async def _async_cancel_and_unsubscribe(self) -> None:
"""Cancel and unsubscribe from PullPoint."""
self._async_cancel_pullpoint_renew()
self.async_cancel_pull_messages()
await self._async_unsubscribe_pullpoint()
async def _async_renew_or_restart_pullpoint(
self, now: dt.datetime | None = None
) -> None: ) -> None:
"""Renew or start pullpoint subscription."""
if self._hass.is_stopping or self.state != PullPointManagerState.STARTED:
return
if self._renew_lock.locked():
LOGGER.debug("%s: PullPoint renew already in progress", self._name)
# Renew is already running, another one will be
# scheduled when the current one is done if needed.
return
async with self._renew_lock:
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
try:
if await self._async_renew_pullpoint():
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
else:
await self._async_restart_pullpoint()
finally:
self.async_schedule_pullpoint_renew(next_attempt)
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
async def _async_create_pullpoint_subscription(self) -> bool:
"""Create pullpoint subscription."""
if not await self._device.create_pullpoint_subscription(
{"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME}
):
LOGGER.debug("%s: Failed to create PullPoint subscription", self._name)
return False
# Create subscription manager
self._pullpoint_subscription = await self._device.create_subscription_service(
"PullPointSubscription"
)
# Create the service that will be used to pull messages from the device.
self._pullpoint_service = await self._device.create_pullpoint_service()
# Initialize events
with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS):
sync_result = await self._pullpoint_service.SetSynchronizationPoint()
LOGGER.debug("%s: SetSynchronizationPoint: %s", self._name, sync_result)
# Always schedule an initial pull messages
self.async_schedule_pull_messages(0.0)
return True
@callback
def _async_cancel_pullpoint_renew(self) -> None:
"""Cancel the pullpoint renew task."""
if self._cancel_pullpoint_renew:
self._cancel_pullpoint_renew()
self._cancel_pullpoint_renew = None
async def _async_restart_pullpoint(self) -> bool:
"""Restart the subscription assuming the camera rebooted."""
self.async_cancel_pull_messages()
await self._async_unsubscribe_pullpoint()
restarted = await self._async_start_pullpoint()
if restarted and self._event_manager.has_listeners:
LOGGER.debug("%s: Restarted PullPoint subscription", self._name)
self.async_schedule_pull_messages(0.0)
return restarted
async def _async_unsubscribe_pullpoint(self) -> None:
"""Unsubscribe the pullpoint subscription."""
if (
not self._pullpoint_subscription
or self._pullpoint_subscription.transport.client.is_closed
):
return
LOGGER.debug("%s: Unsubscribing from PullPoint", self._name)
try:
await self._pullpoint_subscription.Unsubscribe()
except UNSUBSCRIBE_ERRORS as err:
LOGGER.debug(
(
"%s: Failed to unsubscribe PullPoint subscription;"
" This is normal if the device restarted: %s"
),
self._name,
stringify_onvif_error(err),
)
self._pullpoint_subscription = None
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
async def _async_call_pullpoint_subscription_renew(self) -> None:
"""Call PullPoint subscription Renew."""
await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
async def _async_renew_pullpoint(self) -> bool:
"""Renew the PullPoint subscription."""
if (
not self._pullpoint_subscription
or self._pullpoint_subscription.transport.client.is_closed
):
return False
try:
# The first time we renew, we may get a Fault error so we
# suppress it. The subscription will be restarted in
# async_restart later.
await self._async_call_pullpoint_subscription_renew()
LOGGER.debug("%s: Renewed PullPoint subscription", self._name)
return True
except RENEW_ERRORS as err:
self._event_manager.async_mark_events_stale()
LOGGER.debug(
"%s: Failed to renew PullPoint subscription; %s",
self._name,
stringify_onvif_error(err),
)
return False
async def _async_pull_messages_with_lock(self) -> bool:
"""Pull messages from device while holding the lock.
This function must not be called directly, it should only
be called from _async_pull_messages.
Returns True if the subscription is working.
Returns False if the subscription is not working and should be restarted.
"""
assert self._pull_lock.locked(), "Pull lock must be held"
assert self._pullpoint_service is not None, "PullPoint service does not exist"
event_manager = self._event_manager
LOGGER.debug(
"%s: Pulling PullPoint messages timeout=%s limit=%s",
self._name,
PULLPOINT_POLL_TIME,
PULLPOINT_MESSAGE_LIMIT,
)
try:
response = await self._pullpoint_service.PullMessages(
{
"MessageLimit": PULLPOINT_MESSAGE_LIMIT,
"Timeout": PULLPOINT_POLL_TIME,
}
)
except RemoteProtocolError as err:
# Either a shutdown event or the camera closed the connection. Because
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
# to close the connection at any time, we treat this as a normal. Some
# cameras may close the connection if there are no messages to pull.
LOGGER.debug(
"%s: PullPoint subscription encountered a remote protocol error "
"(this is normal for some cameras): %s",
self._name,
stringify_onvif_error(err),
)
return True
except Fault as err:
# Device may not support subscriptions so log at debug level
# when we get an XMLParseError
LOGGER.debug(
"%s: Failed to fetch PullPoint subscription messages: %s",
self._name,
stringify_onvif_error(err),
)
# Treat errors as if the camera restarted. Assume that the pullpoint
# subscription is no longer valid.
return False
except (XMLParseError, RequestError, TimeoutError, TransportError) as err:
LOGGER.debug(
"%s: PullPoint subscription encountered an unexpected error and will be retried "
"(this is normal for some cameras): %s",
self._name,
stringify_onvif_error(err),
)
# Avoid renewing the subscription too often since it causes problems
# for some cameras, mainly the Tapo ones.
return True
if self.state != PullPointManagerState.STARTED:
# If the webhook became started working during the long poll,
# and we got paused, our data is stale and we should not process it.
LOGGER.debug(
"%s: PullPoint is paused (likely due to working webhook), skipping PullPoint messages",
self._name,
)
return True
# Parse response
if (notification_message := response.NotificationMessage) and (
number_of_events := len(notification_message)
):
LOGGER.debug(
"%s: continuous PullMessages: %s event(s)",
self._name,
number_of_events,
)
await event_manager.async_parse_messages(notification_message)
event_manager.async_callback_listeners()
else:
LOGGER.debug("%s: continuous PullMessages: no events", self._name)
return True
@callback
def _async_background_pull_messages(self, _now: dt.datetime | None = None) -> None:
"""Pull messages from device in the background.""" """Pull messages from device in the background."""
self._cancel_pull_messages = None if self._pull_messages_task and not self._pull_messages_task.done():
self._hass.async_create_background_task( LOGGER.debug(
"%s: PullPoint message pull is already in process, skipping pull",
self._name,
)
self.async_schedule_pull_messages()
return
self._pull_messages_task = self._hass.async_create_background_task(
self._async_pull_messages(), self._async_pull_messages(),
f"{self._name} background pull messages", f"{self._name} background pull messages",
) )
async def _async_pull_messages(self) -> None:
"""Pull messages from device."""
event_manager = self._event_manager
if self._pull_lock.locked():
# Pull messages if the lock is not already locked
# any pull will do, so we don't need to wait for the lock
LOGGER.debug(
"%s: PullPoint subscription is already locked, skipping pull",
self._name,
)
return
async with self._pull_lock:
# Before we pop out of the lock we always need to schedule the next pull
# or call async_schedule_pullpoint_renew if the pull fails so the pull
# loop continues.
try:
if self._hass.state == CoreState.running:
if not await self._async_pull_messages_with_lock():
self.async_schedule_pullpoint_renew(0.0)
return
finally:
if event_manager.has_listeners:
self.async_schedule_pull_messages()
class WebHookManager: class WebHookManager:
"""Manage ONVIF webhook subscriptions. """Manage ONVIF webhook subscriptions.
@ -617,21 +484,21 @@ class WebHookManager:
self._event_manager = event_manager self._event_manager = event_manager
self._device = event_manager.device self._device = event_manager.device
self._hass = event_manager.hass self._hass = event_manager.hass
self._webhook_unique_id = f"{DOMAIN}_{event_manager.config_entry.entry_id}" config_entry = event_manager.config_entry
self._old_webhook_unique_id = f"{DOMAIN}_{config_entry.entry_id}"
# Some cameras have a limit on the length of the webhook URL
# so we use a shorter unique ID for the webhook.
unique_id = config_entry.unique_id
assert unique_id is not None
webhook_id = format_mac(unique_id).replace(":", "").lower()
self._webhook_unique_id = f"{DOMAIN}{webhook_id}"
self._name = event_manager.name self._name = event_manager.name
self._webhook_url: str | None = None self._webhook_url: str | None = None
self._webhook_subscription: ONVIFService | None = None
self._notification_manager: NotificationManager | None = None self._notification_manager: NotificationManager | None = None
self._cancel_webhook_renew: CALLBACK_TYPE | None = None
self._renew_lock = asyncio.Lock()
self._renew_or_restart_job = HassJob(
self._async_renew_or_restart_webhook,
f"{self._name}: renew or restart webhook",
)
async def async_start(self) -> bool: async def async_start(self) -> bool:
"""Start polling events.""" """Start polling events."""
LOGGER.debug("%s: Starting webhook manager", self._name) LOGGER.debug("%s: Starting webhook manager", self._name)
@ -649,20 +516,9 @@ class WebHookManager:
async def async_stop(self) -> None: async def async_stop(self) -> None:
"""Unsubscribe from events.""" """Unsubscribe from events."""
self.state = WebHookManagerState.STOPPED self.state = WebHookManagerState.STOPPED
self._async_cancel_webhook_renew()
await self._async_unsubscribe_webhook() await self._async_unsubscribe_webhook()
self._async_unregister_webhook() self._async_unregister_webhook()
@callback
def _async_schedule_webhook_renew(self, delay: float) -> None:
"""Schedule webhook subscription renewal."""
self._async_cancel_webhook_renew()
self._cancel_webhook_renew = async_call_later(
self._hass,
delay,
self._renew_or_restart_job,
)
@retry_connection_error(SUBSCRIPTION_ATTEMPTS) @retry_connection_error(SUBSCRIPTION_ATTEMPTS)
async def _async_create_webhook_subscription(self) -> None: async def _async_create_webhook_subscription(self) -> None:
"""Create webhook subscription.""" """Create webhook subscription."""
@ -671,14 +527,12 @@ class WebHookManager:
self._name, self._name,
self._webhook_url, self._webhook_url,
) )
self._notification_manager = self._device.create_notification_manager(
{
"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME,
"ConsumerReference": {"Address": self._webhook_url},
}
)
try: try:
self._webhook_subscription = await self._notification_manager.setup() self._notification_manager = await self._device.create_notification_manager(
address=self._webhook_url,
interval=SUBSCRIPTION_TIME,
subscription_lost_callback=self._event_manager.async_mark_events_stale,
)
except ValidationError as err: except ValidationError as err:
# This should only happen if there is a problem with the webhook URL # This should only happen if there is a problem with the webhook URL
# that is causing it to not be well formed. # that is causing it to not be well formed.
@ -688,7 +542,7 @@ class WebHookManager:
err, err,
) )
raise raise
await self._notification_manager.start() await self._notification_manager.set_synchronization_point()
LOGGER.debug( LOGGER.debug(
"%s: Webhook subscription created with URL: %s", "%s: Webhook subscription created with URL: %s",
self._name, self._name,
@ -707,62 +561,8 @@ class WebHookManager:
stringify_onvif_error(err), stringify_onvif_error(err),
) )
return False return False
self._async_schedule_webhook_renew(SUBSCRIPTION_RENEW_INTERVAL)
return True return True
async def _async_restart_webhook(self) -> bool:
"""Restart the webhook subscription assuming the camera rebooted."""
await self._async_unsubscribe_webhook()
return await self._async_start_webhook()
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
async def _async_call_webhook_subscription_renew(self) -> None:
"""Call PullPoint subscription Renew."""
assert self._webhook_subscription is not None
await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
async def _async_renew_webhook(self) -> bool:
"""Renew webhook subscription."""
if (
not self._webhook_subscription
or self._webhook_subscription.transport.client.is_closed
):
return False
try:
await self._async_call_webhook_subscription_renew()
LOGGER.debug("%s: Renewed Webhook subscription", self._name)
return True
except RENEW_ERRORS as err:
self._event_manager.async_mark_events_stale()
LOGGER.debug(
"%s: Failed to renew webhook subscription %s",
self._name,
stringify_onvif_error(err),
)
return False
async def _async_renew_or_restart_webhook(
self, now: dt.datetime | None = None
) -> None:
"""Renew or start webhook subscription."""
if self._hass.is_stopping or self.state != WebHookManagerState.STARTED:
return
if self._renew_lock.locked():
LOGGER.debug("%s: Webhook renew already in progress", self._name)
# Renew is already running, another one will be
# scheduled when the current one is done if needed.
return
async with self._renew_lock:
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
try:
if await self._async_renew_webhook():
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
else:
await self._async_restart_webhook()
finally:
self._async_schedule_webhook_renew(next_attempt)
@callback @callback
def _async_register_webhook(self) -> None: def _async_register_webhook(self) -> None:
"""Register the webhook for motion events.""" """Register the webhook for motion events."""
@ -791,6 +591,7 @@ class WebHookManager:
LOGGER.debug( LOGGER.debug(
"%s: Unregistering webhook %s", self._name, self._webhook_unique_id "%s: Unregistering webhook %s", self._name, self._webhook_unique_id
) )
webhook.async_unregister(self._hass, self._old_webhook_unique_id)
webhook.async_unregister(self._hass, self._webhook_unique_id) webhook.async_unregister(self._hass, self._webhook_unique_id)
self._webhook_url = None self._webhook_url = None
@ -842,23 +643,13 @@ class WebHookManager:
await event_manager.async_parse_messages(result.NotificationMessage) await event_manager.async_parse_messages(result.NotificationMessage)
event_manager.async_callback_listeners() event_manager.async_callback_listeners()
@callback
def _async_cancel_webhook_renew(self) -> None:
"""Cancel the webhook renew task."""
if self._cancel_webhook_renew:
self._cancel_webhook_renew()
self._cancel_webhook_renew = None
async def _async_unsubscribe_webhook(self) -> None: async def _async_unsubscribe_webhook(self) -> None:
"""Unsubscribe from the webhook.""" """Unsubscribe from the webhook."""
if ( if not self._notification_manager or self._notification_manager.closed:
not self._webhook_subscription
or self._webhook_subscription.transport.client.is_closed
):
return return
LOGGER.debug("%s: Unsubscribing from webhook", self._name) LOGGER.debug("%s: Unsubscribing from webhook", self._name)
try: try:
await self._webhook_subscription.Unsubscribe() await self._notification_manager.shutdown()
except UNSUBSCRIBE_ERRORS as err: except UNSUBSCRIBE_ERRORS as err:
LOGGER.debug( LOGGER.debug(
( (
@ -868,4 +659,4 @@ class WebHookManager:
self._name, self._name,
stringify_onvif_error(err), stringify_onvif_error(err),
) )
self._webhook_subscription = None self._notification_manager = None

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif", "documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"], "loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": ["onvif-zeep-async==2.1.1", "WSDiscovery==2.0.0"] "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"]
} }

View File

@ -1264,7 +1264,7 @@ ondilo==0.2.0
onkyo-eiscp==1.2.7 onkyo-eiscp==1.2.7
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==2.1.1 onvif-zeep-async==3.1.7
# homeassistant.components.opengarage # homeassistant.components.opengarage
open-garage==0.2.0 open-garage==0.2.0

View File

@ -954,7 +954,7 @@ omnilogic==0.4.5
ondilo==0.2.0 ondilo==0.2.0
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==2.1.1 onvif-zeep-async==3.1.7
# homeassistant.components.opengarage # homeassistant.components.opengarage
open-garage==0.2.0 open-garage==0.2.0