662 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			662 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Python
		
	
	
"""ONVIF event abstraction."""
 | 
						|
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import asyncio
 | 
						|
from collections.abc import Callable
 | 
						|
import datetime as dt
 | 
						|
 | 
						|
from aiohttp.web import Request
 | 
						|
from httpx import RemoteProtocolError, RequestError, TransportError
 | 
						|
from onvif import ONVIFCamera
 | 
						|
from onvif.client import (
 | 
						|
    NotificationManager,
 | 
						|
    PullPointManager as ONVIFPullPointManager,
 | 
						|
    retry_connection_error,
 | 
						|
)
 | 
						|
from onvif.exceptions import ONVIFError
 | 
						|
from onvif.util import stringify_onvif_error
 | 
						|
from zeep.exceptions import Fault, ValidationError, XMLParseError
 | 
						|
 | 
						|
from homeassistant.components import webhook
 | 
						|
from homeassistant.config_entries import ConfigEntry
 | 
						|
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
 | 
						|
from homeassistant.helpers.device_registry import format_mac
 | 
						|
from homeassistant.helpers.event import async_call_later
 | 
						|
from homeassistant.helpers.network import NoURLAvailableError, get_url
 | 
						|
 | 
						|
from .const import DOMAIN, LOGGER
 | 
						|
from .models import Event, PullPointManagerState, WebHookManagerState
 | 
						|
from .parsers import PARSERS
 | 
						|
 | 
						|
# Topics in this list are ignored because we do not want to create
 | 
						|
# entities for them.
 | 
						|
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
 | 
						|
 | 
						|
SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError)
 | 
						|
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError)
 | 
						|
SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
 | 
						|
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
 | 
						|
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
 | 
						|
#
 | 
						|
# We only keep the subscription alive for 10 minutes, and will keep
 | 
						|
# renewing it every 8 minutes. This is to avoid the camera
 | 
						|
# accumulating subscriptions which will be impossible to clean up
 | 
						|
# since ONVIF does not provide a way to list existing subscriptions.
 | 
						|
#
 | 
						|
# If we max out the number of subscriptions, the camera will stop
 | 
						|
# sending events to us, and we will not be able to recover until
 | 
						|
# the subscriptions expire or the camera is rebooted.
 | 
						|
#
 | 
						|
SUBSCRIPTION_TIME = dt.timedelta(minutes=10)
 | 
						|
 | 
						|
# SUBSCRIPTION_RENEW_INTERVAL Must be less than the
 | 
						|
# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds
 | 
						|
#
 | 
						|
# We use 8 minutes between renewals to make sure we never hit the
 | 
						|
# 10 minute limit even if the first renewal attempt fails
 | 
						|
SUBSCRIPTION_RENEW_INTERVAL = 8 * 60
 | 
						|
 | 
						|
# The number of attempts to make when creating or renewing a subscription
 | 
						|
SUBSCRIPTION_ATTEMPTS = 2
 | 
						|
 | 
						|
# The time to wait before trying to restart the subscription if it fails
 | 
						|
SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = 60
 | 
						|
 | 
						|
PULLPOINT_POLL_TIME = dt.timedelta(seconds=60)
 | 
						|
PULLPOINT_MESSAGE_LIMIT = 100
 | 
						|
PULLPOINT_COOLDOWN_TIME = 0.75
 | 
						|
 | 
						|
 | 
						|
class EventManager:
 | 
						|
    """ONVIF Event Manager."""
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        hass: HomeAssistant,
 | 
						|
        device: ONVIFCamera,
 | 
						|
        config_entry: ConfigEntry,
 | 
						|
        name: str,
 | 
						|
    ) -> None:
 | 
						|
        """Initialize event manager."""
 | 
						|
        self.hass = hass
 | 
						|
        self.device = device
 | 
						|
        self.config_entry = config_entry
 | 
						|
        self.unique_id = config_entry.unique_id
 | 
						|
        self.name = name
 | 
						|
 | 
						|
        self.webhook_manager = WebHookManager(self)
 | 
						|
        self.pullpoint_manager = PullPointManager(self)
 | 
						|
 | 
						|
        self._uid_by_platform: dict[str, set[str]] = {}
 | 
						|
        self._events: dict[str, Event] = {}
 | 
						|
        self._listeners: list[CALLBACK_TYPE] = []
 | 
						|
 | 
						|
    @property
 | 
						|
    def started(self) -> bool:
 | 
						|
        """Return True if event manager is started."""
 | 
						|
        return (
 | 
						|
            self.webhook_manager.state == WebHookManagerState.STARTED
 | 
						|
            or self.pullpoint_manager.state == PullPointManagerState.STARTED
 | 
						|
        )
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]:
 | 
						|
        """Listen for data updates."""
 | 
						|
        # We always have to listen for events or we will never
 | 
						|
        # know which sensors to create. In practice we always have
 | 
						|
        # a listener anyways since binary_sensor and sensor will
 | 
						|
        # create a listener when they are created.
 | 
						|
        self._listeners.append(update_callback)
 | 
						|
 | 
						|
        @callback
 | 
						|
        def remove_listener() -> None:
 | 
						|
            """Remove update listener."""
 | 
						|
            self.async_remove_listener(update_callback)
 | 
						|
 | 
						|
        return remove_listener
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
 | 
						|
        """Remove data update."""
 | 
						|
        if update_callback in self._listeners:
 | 
						|
            self._listeners.remove(update_callback)
 | 
						|
 | 
						|
    async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool:
 | 
						|
        """Start polling events."""
 | 
						|
        # Always start pull point first, since it will populate the event list
 | 
						|
        event_via_pull_point = (
 | 
						|
            try_pullpoint and await self.pullpoint_manager.async_start()
 | 
						|
        )
 | 
						|
        events_via_webhook = try_webhook and await self.webhook_manager.async_start()
 | 
						|
        return events_via_webhook or event_via_pull_point
 | 
						|
 | 
						|
    async def async_stop(self) -> None:
 | 
						|
        """Unsubscribe from events."""
 | 
						|
        self._listeners = []
 | 
						|
        await self.pullpoint_manager.async_stop()
 | 
						|
        await self.webhook_manager.async_stop()
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_callback_listeners(self) -> None:
 | 
						|
        """Update listeners."""
 | 
						|
        for update_callback in self._listeners:
 | 
						|
            update_callback()
 | 
						|
 | 
						|
    async def async_parse_messages(self, messages) -> None:
 | 
						|
        """Parse notification message."""
 | 
						|
        unique_id = self.unique_id
 | 
						|
        assert unique_id is not None
 | 
						|
        for msg in messages:
 | 
						|
            # Guard against empty message
 | 
						|
            if not msg.Topic:
 | 
						|
                continue
 | 
						|
 | 
						|
            # Topic may look like the following
 | 
						|
            #
 | 
						|
            # tns1:RuleEngine/CellMotionDetector/Motion//.
 | 
						|
            # tns1:RuleEngine/CellMotionDetector/Motion
 | 
						|
            # tns1:RuleEngine/CellMotionDetector/Motion/
 | 
						|
            # tns1:UserAlarm/IVA/HumanShapeDetect
 | 
						|
            #
 | 
						|
            # Our parser expects the topic to be
 | 
						|
            # tns1:RuleEngine/CellMotionDetector/Motion
 | 
						|
            topic = msg.Topic._value_1.rstrip("/.")  # noqa: SLF001
 | 
						|
 | 
						|
            if not (parser := PARSERS.get(topic)):
 | 
						|
                if topic not in UNHANDLED_TOPICS:
 | 
						|
                    LOGGER.warning(
 | 
						|
                        "%s: No registered handler for event from %s: %s",
 | 
						|
                        self.name,
 | 
						|
                        unique_id,
 | 
						|
                        msg,
 | 
						|
                    )
 | 
						|
                    UNHANDLED_TOPICS.add(topic)
 | 
						|
                continue
 | 
						|
 | 
						|
            event = await parser(unique_id, msg)
 | 
						|
 | 
						|
            if not event:
 | 
						|
                LOGGER.warning(
 | 
						|
                    "%s: Unable to parse event from %s: %s", self.name, unique_id, msg
 | 
						|
                )
 | 
						|
                return
 | 
						|
 | 
						|
            self.get_uids_by_platform(event.platform).add(event.uid)
 | 
						|
            self._events[event.uid] = event
 | 
						|
 | 
						|
    def get_uid(self, uid: str) -> Event | None:
 | 
						|
        """Retrieve event for given id."""
 | 
						|
        return self._events.get(uid)
 | 
						|
 | 
						|
    def get_platform(self, platform) -> list[Event]:
 | 
						|
        """Retrieve events for given platform."""
 | 
						|
        return [event for event in self._events.values() if event.platform == platform]
 | 
						|
 | 
						|
    def get_uids_by_platform(self, platform: str) -> set[str]:
 | 
						|
        """Retrieve uids for a given platform."""
 | 
						|
        if (possible_uids := self._uid_by_platform.get(platform)) is None:
 | 
						|
            uids: set[str] = set()
 | 
						|
            self._uid_by_platform[platform] = uids
 | 
						|
            return uids
 | 
						|
        return possible_uids
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_webhook_failed(self) -> None:
 | 
						|
        """Mark webhook as failed."""
 | 
						|
        if self.pullpoint_manager.state != PullPointManagerState.PAUSED:
 | 
						|
            return
 | 
						|
        LOGGER.debug("%s: Switching to PullPoint for events", self.name)
 | 
						|
        self.pullpoint_manager.async_resume()
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_webhook_working(self) -> None:
 | 
						|
        """Mark webhook as working."""
 | 
						|
        if self.pullpoint_manager.state != PullPointManagerState.STARTED:
 | 
						|
            return
 | 
						|
        LOGGER.debug("%s: Switching to webhook for events", self.name)
 | 
						|
        self.pullpoint_manager.async_pause()
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_mark_events_stale(self) -> None:
 | 
						|
        """Mark all events as stale when the subscriptions fail since we are out of sync."""
 | 
						|
        self._events.clear()
 | 
						|
        self.async_callback_listeners()
 | 
						|
 | 
						|
 | 
						|
class PullPointManager:
 | 
						|
    """ONVIF PullPoint Manager.
 | 
						|
 | 
						|
    If the camera supports webhooks and the webhook is reachable, the pullpoint
 | 
						|
    manager will keep the pull point subscription alive, but will not poll for
 | 
						|
    messages unless the webhook fails.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, event_manager: EventManager) -> None:
 | 
						|
        """Initialize pullpoint manager."""
 | 
						|
        self.state = PullPointManagerState.STOPPED
 | 
						|
 | 
						|
        self._event_manager = event_manager
 | 
						|
        self._device = event_manager.device
 | 
						|
        self._hass = event_manager.hass
 | 
						|
        self._name = event_manager.name
 | 
						|
 | 
						|
        self._pullpoint_manager: ONVIFPullPointManager | None = None
 | 
						|
 | 
						|
        self._cancel_pull_messages: CALLBACK_TYPE | None = None
 | 
						|
        self._pull_messages_job = HassJob(
 | 
						|
            self._async_background_pull_messages_or_reschedule,
 | 
						|
            f"{self._name}: pull messages",
 | 
						|
        )
 | 
						|
        self._pull_messages_task: asyncio.Task[None] | None = None
 | 
						|
 | 
						|
    async def async_start(self) -> bool:
 | 
						|
        """Start pullpoint subscription."""
 | 
						|
        assert self.state == PullPointManagerState.STOPPED, (
 | 
						|
            "PullPoint manager already started"
 | 
						|
        )
 | 
						|
        LOGGER.debug("%s: Starting PullPoint manager", self._name)
 | 
						|
        if not await self._async_start_pullpoint():
 | 
						|
            self.state = PullPointManagerState.FAILED
 | 
						|
            return False
 | 
						|
        self.state = PullPointManagerState.STARTED
 | 
						|
        self.async_schedule_pull_messages()
 | 
						|
        return True
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_pause(self) -> None:
 | 
						|
        """Pause pullpoint subscription."""
 | 
						|
        LOGGER.debug("%s: Pausing PullPoint manager", self._name)
 | 
						|
        self.state = PullPointManagerState.PAUSED
 | 
						|
        # Cancel the renew job so we don't renew the subscription
 | 
						|
        # and stop pulling messages.
 | 
						|
        self.async_cancel_pull_messages()
 | 
						|
        if self._pullpoint_manager:
 | 
						|
            self._pullpoint_manager.pause()
 | 
						|
        # We do not unsubscribe from the pullpoint subscription and instead
 | 
						|
        # let the subscription expire since some cameras will terminate all
 | 
						|
        # subscriptions if we unsubscribe which will break the webhook.
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_resume(self) -> None:
 | 
						|
        """Resume pullpoint subscription."""
 | 
						|
        LOGGER.debug("%s: Resuming PullPoint manager", self._name)
 | 
						|
        self.state = PullPointManagerState.STARTED
 | 
						|
        if self._pullpoint_manager:
 | 
						|
            self._pullpoint_manager.resume()
 | 
						|
        self.async_schedule_pull_messages()
 | 
						|
 | 
						|
    async def async_stop(self) -> None:
 | 
						|
        """Unsubscribe from PullPoint and cancel callbacks."""
 | 
						|
        self.state = PullPointManagerState.STOPPED
 | 
						|
        await self._async_cancel_and_unsubscribe()
 | 
						|
 | 
						|
    async def _async_start_pullpoint(self) -> bool:
 | 
						|
        """Start pullpoint subscription."""
 | 
						|
        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
 | 
						|
        )
 | 
						|
        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
 | 
						|
        service = self._pullpoint_manager.get_service()
 | 
						|
        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 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
 | 
						|
    def async_cancel_pull_messages(self) -> None:
 | 
						|
        """Cancel the PullPoint task."""
 | 
						|
        if self._cancel_pull_messages:
 | 
						|
            self._cancel_pull_messages()
 | 
						|
            self._cancel_pull_messages = None
 | 
						|
 | 
						|
    @callback
 | 
						|
    def async_schedule_pull_messages(self, delay: float | None = None) -> None:
 | 
						|
        """Schedule async_pull_messages to run.
 | 
						|
 | 
						|
        Used as fallback when webhook is not working.
 | 
						|
 | 
						|
        Must not check if the webhook is working.
 | 
						|
        """
 | 
						|
        self.async_cancel_pull_messages()
 | 
						|
        if self.state != PullPointManagerState.STARTED:
 | 
						|
            return
 | 
						|
        if self._pullpoint_manager:
 | 
						|
            when = delay if delay is not None else PULLPOINT_COOLDOWN_TIME
 | 
						|
            self._cancel_pull_messages = async_call_later(
 | 
						|
                self._hass, when, self._pull_messages_job
 | 
						|
            )
 | 
						|
 | 
						|
    @callback
 | 
						|
    def _async_background_pull_messages_or_reschedule(
 | 
						|
        self, _now: dt.datetime | None = None
 | 
						|
    ) -> None:
 | 
						|
        """Pull messages from device in the background."""
 | 
						|
        if self._pull_messages_task and not self._pull_messages_task.done():
 | 
						|
            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(),
 | 
						|
            f"{self._name} background pull messages",
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
class WebHookManager:
 | 
						|
    """Manage ONVIF webhook subscriptions.
 | 
						|
 | 
						|
    If the camera supports webhooks, we will use that instead of
 | 
						|
    pullpoint subscriptions as soon as we detect that the camera
 | 
						|
    can reach our webhook.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, event_manager: EventManager) -> None:
 | 
						|
        """Initialize webhook manager."""
 | 
						|
        self.state = WebHookManagerState.STOPPED
 | 
						|
 | 
						|
        self._event_manager = event_manager
 | 
						|
        self._device = event_manager.device
 | 
						|
        self._hass = event_manager.hass
 | 
						|
        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._webhook_url: str | None = None
 | 
						|
 | 
						|
        self._notification_manager: NotificationManager | None = None
 | 
						|
 | 
						|
    async def async_start(self) -> bool:
 | 
						|
        """Start polling events."""
 | 
						|
        LOGGER.debug("%s: Starting webhook manager", self._name)
 | 
						|
        assert self.state == WebHookManagerState.STOPPED, (
 | 
						|
            "Webhook manager already started"
 | 
						|
        )
 | 
						|
        assert self._webhook_url is None, "Webhook already registered"
 | 
						|
        self._async_register_webhook()
 | 
						|
        if not await self._async_start_webhook():
 | 
						|
            self.state = WebHookManagerState.FAILED
 | 
						|
            return False
 | 
						|
        self.state = WebHookManagerState.STARTED
 | 
						|
        return True
 | 
						|
 | 
						|
    async def async_stop(self) -> None:
 | 
						|
        """Unsubscribe from events."""
 | 
						|
        self.state = WebHookManagerState.STOPPED
 | 
						|
        await self._async_unsubscribe_webhook()
 | 
						|
        self._async_unregister_webhook()
 | 
						|
 | 
						|
    @retry_connection_error(SUBSCRIPTION_ATTEMPTS)
 | 
						|
    async def _async_create_webhook_subscription(self) -> None:
 | 
						|
        """Create webhook subscription."""
 | 
						|
        LOGGER.debug(
 | 
						|
            "%s: Creating webhook subscription with URL: %s",
 | 
						|
            self._name,
 | 
						|
            self._webhook_url,
 | 
						|
        )
 | 
						|
        try:
 | 
						|
            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:
 | 
						|
            # This should only happen if there is a problem with the webhook URL
 | 
						|
            # that is causing it to not be well formed.
 | 
						|
            LOGGER.exception(
 | 
						|
                "%s: validation error while creating webhook subscription: %s",
 | 
						|
                self._name,
 | 
						|
                err,
 | 
						|
            )
 | 
						|
            raise
 | 
						|
        await self._notification_manager.set_synchronization_point()
 | 
						|
        LOGGER.debug(
 | 
						|
            "%s: Webhook subscription created with URL: %s",
 | 
						|
            self._name,
 | 
						|
            self._webhook_url,
 | 
						|
        )
 | 
						|
 | 
						|
    async def _async_start_webhook(self) -> bool:
 | 
						|
        """Start webhook."""
 | 
						|
        try:
 | 
						|
            await self._async_create_webhook_subscription()
 | 
						|
        except CREATE_ERRORS as err:
 | 
						|
            self._event_manager.async_webhook_failed()
 | 
						|
            LOGGER.debug(
 | 
						|
                "%s: Device does not support notification service or too many subscriptions: %s",
 | 
						|
                self._name,
 | 
						|
                stringify_onvif_error(err),
 | 
						|
            )
 | 
						|
            return False
 | 
						|
        return True
 | 
						|
 | 
						|
    @callback
 | 
						|
    def _async_register_webhook(self) -> None:
 | 
						|
        """Register the webhook for motion events."""
 | 
						|
        LOGGER.debug("%s: Registering webhook: %s", self._name, self._webhook_unique_id)
 | 
						|
 | 
						|
        try:
 | 
						|
            base_url = get_url(self._hass, prefer_external=False)
 | 
						|
        except NoURLAvailableError:
 | 
						|
            try:
 | 
						|
                base_url = get_url(self._hass, prefer_external=True)
 | 
						|
            except NoURLAvailableError:
 | 
						|
                return
 | 
						|
 | 
						|
        webhook_id = self._webhook_unique_id
 | 
						|
        self._async_unregister_webhook()
 | 
						|
        webhook.async_register(
 | 
						|
            self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook
 | 
						|
        )
 | 
						|
        webhook_path = webhook.async_generate_path(webhook_id)
 | 
						|
        self._webhook_url = f"{base_url}{webhook_path}"
 | 
						|
        LOGGER.debug("%s: Registered webhook: %s", self._name, webhook_id)
 | 
						|
 | 
						|
    @callback
 | 
						|
    def _async_unregister_webhook(self):
 | 
						|
        """Unregister the webhook for motion events."""
 | 
						|
        LOGGER.debug(
 | 
						|
            "%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)
 | 
						|
        self._webhook_url = None
 | 
						|
 | 
						|
    async def _async_handle_webhook(
 | 
						|
        self, hass: HomeAssistant, webhook_id: str, request: Request
 | 
						|
    ) -> None:
 | 
						|
        """Handle incoming webhook."""
 | 
						|
        content: bytes | None = None
 | 
						|
        try:
 | 
						|
            content = await request.read()
 | 
						|
        except ConnectionResetError as ex:
 | 
						|
            LOGGER.error("Error reading webhook: %s", ex)
 | 
						|
            return
 | 
						|
        except asyncio.CancelledError as ex:
 | 
						|
            LOGGER.error("Error reading webhook: %s", ex)
 | 
						|
            raise
 | 
						|
        finally:
 | 
						|
            self._hass.async_create_background_task(
 | 
						|
                self._async_process_webhook(hass, webhook_id, content),
 | 
						|
                f"ONVIF event webhook for {self._name}",
 | 
						|
            )
 | 
						|
 | 
						|
    async def _async_process_webhook(
 | 
						|
        self, hass: HomeAssistant, webhook_id: str, content: bytes | None
 | 
						|
    ) -> None:
 | 
						|
        """Process incoming webhook data in the background."""
 | 
						|
        event_manager = self._event_manager
 | 
						|
        if content is None:
 | 
						|
            # webhook is marked as not working as something
 | 
						|
            # went wrong. We will mark it as working again
 | 
						|
            # when we receive a valid notification.
 | 
						|
            event_manager.async_webhook_failed()
 | 
						|
            return
 | 
						|
        if not self._notification_manager:
 | 
						|
            LOGGER.debug(
 | 
						|
                "%s: Received webhook before notification manager is setup", self._name
 | 
						|
            )
 | 
						|
            return
 | 
						|
        if not (result := self._notification_manager.process(content)):
 | 
						|
            LOGGER.debug("%s: Failed to process webhook %s", self._name, webhook_id)
 | 
						|
            return
 | 
						|
        LOGGER.debug(
 | 
						|
            "%s: Processed webhook %s with %s event(s)",
 | 
						|
            self._name,
 | 
						|
            webhook_id,
 | 
						|
            len(result.NotificationMessage),
 | 
						|
        )
 | 
						|
        event_manager.async_webhook_working()
 | 
						|
        await event_manager.async_parse_messages(result.NotificationMessage)
 | 
						|
        event_manager.async_callback_listeners()
 | 
						|
 | 
						|
    async def _async_unsubscribe_webhook(self) -> None:
 | 
						|
        """Unsubscribe from the webhook."""
 | 
						|
        if not self._notification_manager or self._notification_manager.closed:
 | 
						|
            return
 | 
						|
        LOGGER.debug("%s: Unsubscribing from webhook", self._name)
 | 
						|
        try:
 | 
						|
            await self._notification_manager.shutdown()
 | 
						|
        except UNSUBSCRIBE_ERRORS as err:
 | 
						|
            LOGGER.debug(
 | 
						|
                (
 | 
						|
                    "%s: Failed to unsubscribe webhook subscription;"
 | 
						|
                    " This is normal if the device restarted: %s"
 | 
						|
                ),
 | 
						|
                self._name,
 | 
						|
                stringify_onvif_error(err),
 | 
						|
            )
 | 
						|
        self._notification_manager = None
 |