core/homeassistant/components/onvif/__init__.py

225 lines
7.7 KiB
Python

"""The ONVIF integration."""
import asyncio
from contextlib import AsyncExitStack, suppress
from http import HTTPStatus
import logging
import aiohttp
from onvif.exceptions import ONVIFError
from onvif.util import is_auth_error, stringify_onvif_error
from zeep.exceptions import Fault, TransportError
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS
from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_ENABLE_WEBHOOKS,
CONF_SNAPSHOT_AUTH,
DEFAULT_ARGUMENTS,
DEFAULT_ENABLE_WEBHOOKS,
DOMAIN,
)
from .device import ONVIFDevice
LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up ONVIF from a config entry."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
if not entry.options:
await async_populate_options(hass, entry)
device = ONVIFDevice(hass, entry)
async with AsyncExitStack() as stack:
# Register cleanup callback for device
@stack.push_async_callback
async def _cleanup():
await _async_stop_device(hass, device)
try:
await device.async_setup()
if not entry.data.get(CONF_SNAPSHOT_AUTH):
await async_populate_snapshot_auth(hass, device, entry)
except (TimeoutError, aiohttp.ClientError) as err:
raise ConfigEntryNotReady(
f"Could not connect to camera {device.device.host}:{device.device.port}: {err}"
) from err
except Fault as err:
if is_auth_error(err):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringify_onvif_error(err)}"
) from err
raise ConfigEntryNotReady(
f"Could not connect to camera: {stringify_onvif_error(err)}"
) from err
except ONVIFError as err:
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}"
) from err
except TransportError as err:
stringified_onvif_error = stringify_onvif_error(err)
if err.status_code in (
HTTPStatus.UNAUTHORIZED.value,
HTTPStatus.FORBIDDEN.value,
):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringified_onvif_error}"
) from err
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}"
) from err
except asyncio.CancelledError as err:
# After https://github.com/agronholm/anyio/issues/374 is resolved
# this may be able to be removed
raise ConfigEntryNotReady(
f"Setup was unexpectedly canceled: {err}"
) from err
if not device.available:
raise ConfigEntryNotReady
# If we get here, setup was successful - prevent cleanup
stack.pop_all()
hass.data[DOMAIN][entry.unique_id] = device
device.platforms = [Platform.BUTTON, Platform.CAMERA]
if device.capabilities.events:
device.platforms += [Platform.BINARY_SENSOR, Platform.SENSOR]
if device.capabilities.imaging:
device.platforms += [Platform.SWITCH]
_async_migrate_camera_entities_unique_ids(hass, entry, device)
await hass.config_entries.async_forward_entry_setups(entry, device.platforms)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.async_stop)
)
return True
async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None:
"""Stop the ONVIF device."""
if device.capabilities.events and device.events.started:
try:
await device.events.async_stop()
except (TimeoutError, ONVIFError, Fault, aiohttp.ClientError, TransportError):
LOGGER.warning("Error while stopping events: %s", device.name)
await device.device.close()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id]
await _async_stop_device(hass, device)
return await hass.config_entries.async_unload_platforms(entry, device.platforms)
async def _get_snapshot_auth(device: ONVIFDevice) -> str | None:
"""Determine auth type for snapshots."""
if not device.capabilities.snapshot:
return None
for basic_auth in (False, True):
method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION
with suppress(ONVIFError):
if await device.device.get_snapshot(device.profiles[0].token, basic_auth):
return method
return None
async def async_populate_snapshot_auth(
hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry
) -> None:
"""Check if digest auth for snapshots is possible."""
if auth := await _get_snapshot_auth(device):
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth}
)
async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Populate default options for device."""
options = {
CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS,
CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)),
CONF_ENABLE_WEBHOOKS: DEFAULT_ENABLE_WEBHOOKS,
}
hass.config_entries.async_update_entry(entry, options=options)
@callback
def _async_migrate_camera_entities_unique_ids(
hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice
) -> None:
"""Migrate unique ids of camera entities from profile index to profile token."""
entity_reg = er.async_get(hass)
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id
)
mac_or_serial = device.info.mac or device.info.serial_number
old_uid_start = f"{mac_or_serial}_"
new_uid_start = f"{mac_or_serial}#"
for entity in entities:
if entity.domain != Platform.CAMERA:
continue
if (
not entity.unique_id.startswith(old_uid_start)
and entity.unique_id != mac_or_serial
):
continue
index = 0
if entity.unique_id.startswith(old_uid_start):
try:
index = int(entity.unique_id[len(old_uid_start) :])
except ValueError:
LOGGER.error(
"Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'",
entity.entity_id,
entity.unique_id,
)
continue
try:
token = device.profiles[index].token
except IndexError:
LOGGER.error(
"Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found",
entity.entity_id,
index,
entity.unique_id,
)
continue
new_uid = f"{new_uid_start}{token}"
LOGGER.debug(
"Migrating unique id for '%s' from '%s' to '%s'",
entity.entity_id,
entity.unique_id,
new_uid,
)
entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid)