home-automation-home-assistant/custom_components/reolink_dev/base.py

367 lines
11 KiB
Python

"""This component updates the camera API and subscription."""
import logging
import re
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TIMEOUT,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import get_url
from homeassistant.helpers.entity_registry import (
async_entries_for_config_entry,
async_get_registry as async_get_entity_registry,
)
from reolink.camera_api import Api
from reolink.subscription_manager import Manager
from .const import (
BASE,
CONF_PLAYBACK_MONTHS,
CONF_PLAYBACK_THUMBNAILS,
CONF_THUMBNAIL_OFFSET,
DEFAULT_PLAYBACK_MONTHS,
DEFAULT_PLAYBACK_THUMBNAILS,
DEFAULT_THUMBNAIL_OFFSET,
EVENT_DATA_RECEIVED,
CONF_CHANNEL,
CONF_MOTION_OFF_DELAY,
CONF_PROTOCOL,
CONF_STREAM,
DEFAULT_CHANNEL,
DEFAULT_MOTION_OFF_DELAY,
DEFAULT_PROTOCOL,
DEFAULT_STREAM,
DEFAULT_TIMEOUT,
DOMAIN,
PUSH_MANAGER,
SESSION_RENEW_THRESHOLD,
)
_LOGGER = logging.getLogger(__name__)
class ReolinkBase:
"""The implementation of the Reolink IP base class."""
def __init__(
self, hass: HomeAssistant, config: dict, options: dict
): # pylint: disable=too-many-arguments
"""Initialize a Reolink camera."""
self._username = config[CONF_USERNAME]
self._password = config[CONF_PASSWORD]
if CONF_CHANNEL not in config:
self._channel = DEFAULT_CHANNEL
else:
self._channel = config[CONF_CHANNEL]
if CONF_TIMEOUT not in options:
self._timeout = DEFAULT_TIMEOUT
else:
self._timeout = options[CONF_TIMEOUT]
if CONF_STREAM not in options:
self._stream = DEFAULT_STREAM
else:
self._stream = options[CONF_STREAM]
if CONF_PROTOCOL not in options:
self._protocol = DEFAULT_PROTOCOL
else:
self._protocol = options[CONF_PROTOCOL]
self._api = Api(
config[CONF_HOST],
config[CONF_PORT],
self._username,
self._password,
channel=self._channel - 1,
stream=self._stream,
protocol=self._protocol,
timeout=self._timeout,
)
self._hass = hass
self.sync_functions = list()
self.motion_detection_state = True
if CONF_MOTION_OFF_DELAY not in options:
self.motion_off_delay = DEFAULT_MOTION_OFF_DELAY
else:
self.motion_off_delay = options[CONF_MOTION_OFF_DELAY]
if CONF_PLAYBACK_MONTHS not in options:
self.playback_months = DEFAULT_PLAYBACK_MONTHS
else:
self.playback_months = options[CONF_PLAYBACK_MONTHS]
if CONF_PLAYBACK_THUMBNAILS not in options:
self.playback_thumbnails = DEFAULT_PLAYBACK_THUMBNAILS
else:
self.playback_thumbnails = options[CONF_PLAYBACK_THUMBNAILS]
if CONF_THUMBNAIL_OFFSET not in options:
self.playback_thumbnail_offset = DEFAULT_THUMBNAIL_OFFSET
else:
self.playback_thumbnail_offset = options[CONF_THUMBNAIL_OFFSET]
@property
def name(self):
"""Create the device name."""
return self._api.name
@property
def unique_id(self):
"""Create the unique ID, base for all entities."""
id = self._api.mac_address.replace(":", "")
return f"{id}-{self.channel}"
@property
def event_id(self):
"""Create the event ID string."""
event_id = self._api.mac_address.replace(":", "")
return f"{EVENT_DATA_RECEIVED}-{event_id}"
@property
def push_manager(self):
"""Create the event ID string."""
push_id = self._api.mac_address.replace(":", "")
return f"{PUSH_MANAGER}-{push_id}"
@property
def timeout(self):
"""Return the timeout setting."""
return self._timeout
@property
def channel(self):
"""Return the channel setting."""
return self._channel
@property
def api(self):
"""Return the API object."""
return self._api
async def connect_api(self):
"""Connect to the Reolink API and fetch initial dataset."""
if not await self._api.get_settings():
return False
if not await self._api.get_states():
return False
await self._api.is_admin()
return True
async def set_channel(self, channel):
"""Set the API channel."""
self._channel = channel
await self._api.set_channel(channel - 1)
async def set_protocol(self, protocol):
"""Set the protocol."""
self._protocol = protocol
await self._api.set_protocol(protocol)
async def set_stream(self, stream):
"""Set the stream."""
self._stream = stream
await self._api.set_stream(stream)
async def set_timeout(self, timeout):
"""Set the API timeout."""
self._timeout = timeout
await self._api.set_timeout(timeout)
async def update_states(self):
"""Call the API of the camera device to update the states."""
await self._api.get_states()
async def update_settings(self):
"""Call the API of the camera device to update the settings."""
await self._api.get_settings()
async def disconnect_api(self):
"""Disconnect from the API, so the connection will be released."""
await self._api.logout()
async def stop(self):
"""Disconnect the API and deregister the event listener."""
await self.disconnect_api()
for func in self.sync_functions:
await self._hass.async_add_executor_job(func)
class ReolinkPush:
"""The implementation of the Reolink IP base class."""
def __init__(
self, hass: HomeAssistant, host, port, username, password
): # pylint: disable=too-many-arguments
"""Initialize a Reolink camera."""
self._host = host
self._port = port
self._username = username
self._password = password
self._hass = hass
self._sman = None
self._webhook_url = None
self._webhook_id = None
self._event_id = None
@property
def sman(self):
"""Return the session manager object."""
return self._sman
async def subscribe(self, event_id):
"""Subscribe to motion events and set the webhook as callback."""
self._event_id = event_id
self._webhook_id = await self.register_webhook()
self._webhook_url = "{}{}".format(
get_url(self._hass, prefer_external=False),
self._hass.components.webhook.async_generate_path(self._webhook_id),
)
self._sman = Manager(self._host, self._port, self._username, self._password)
if await self._sman.subscribe(self._webhook_url):
_LOGGER.info(
"Host %s subscribed successfully to webhook %s",
self._host,
self._webhook_url,
)
await self.set_available(True)
else:
await self.set_available(False)
return True
async def register_webhook(self):
"""
Register a webhook for motion events if it does not exist yet (in case of NVR).
The webhook name (in info) contains the event id (contains mac address op the camera).
So when motion triggers the webhook, it triggers this event. The event is handled by
the binary sensor, in case of NVR the binary sensor also figures out what channel has
the motion. So the flow is: camera onvif event->webhook->HA event->binary sensor.
"""
_LOGGER.debug("Registering webhook for event ID %s", self._event_id)
webhook_id = self._hass.components.webhook.async_generate_id()
self._hass.components.webhook.async_register(
DOMAIN, self._event_id, webhook_id, handle_webhook
)
return webhook_id
async def renew(self):
"""Renew the subscription of the motion events (lease time is set to 15 minutes)."""
if self._sman.renewtimer <= SESSION_RENEW_THRESHOLD:
if not await self._sman.renew():
_LOGGER.error(
"Host %s error renewing the Reolink subscription",
self._host,
)
await self.set_available(False)
await self._sman.subscribe(self._webhook_url)
else:
await self.set_available(True)
else:
await self.set_available(True)
async def set_available(self, available: bool):
"""Set the availability state to the base object."""
self._hass.bus.async_fire(self._event_id, {"available": available})
async def unsubscribe(self):
"""Unsubscribe from the motion events."""
await self.set_available(False)
await self.unregister_webhook()
return await self._sman.unsubscribe()
async def unregister_webhook(self):
"""Unregister the webhook for motion events."""
_LOGGER.debug("Unregistering webhook %s", self._webhook_id)
self._hass.components.webhook.async_unregister(self._webhook_id)
async def count_members(self):
"""Count the number of camera's using this push manager."""
members = 0
for entry_id in self._hass.data[DOMAIN]:
_LOGGER.debug("Got data entry: %s", entry_id)
if PUSH_MANAGER in entry_id:
continue # Count config entries only
try:
base = self._hass.data[DOMAIN][entry_id][BASE]
if base.event_id == self._event_id:
members += 1
except AttributeError:
pass
except KeyError:
pass
_LOGGER.debug("Found %d listeners for event %s", members, self._event_id)
return members
async def handle_webhook(hass, webhook_id, request):
"""Handle incoming webhook from Reolink for inbound messages and calls."""
_LOGGER.debug("Reolink webhook triggered")
if not request.body_exists:
_LOGGER.debug("Webhook triggered without payload")
data = await request.text()
if not data:
_LOGGER.debug("Webhook triggered with unknown payload")
return
_LOGGER.debug(data)
matches = re.findall(r'Name="IsMotion" Value="(.+?)"', data)
if matches:
is_motion = matches[0] == "true"
else:
_LOGGER.debug("Webhook triggered with unknown payload")
return
event_id = await get_event_by_webhook(hass, webhook_id)
if not event_id:
_LOGGER.error("Webhook triggered without event to fire")
hass.bus.async_fire(event_id, {"motion": is_motion})
async def get_webhook_by_event(hass: HomeAssistant, event_id):
"""Find the webhook_id by the event_id."""
try:
handlers = hass.data["webhook"]
except KeyError:
return
for wid, info in handlers.items():
_LOGGER.debug("Webhook: %s", wid)
_LOGGER.debug(info)
if info["name"] == event_id:
return wid
async def get_event_by_webhook(hass: HomeAssistant, webhook_id):
"""Find the event_id by the webhook_id."""
try:
handlers = hass.data["webhook"]
except KeyError:
return
for wid, info in handlers.items():
if wid == webhook_id:
event_id = info["name"]
return event_id