mirror of https://github.com/home-assistant/core
236 lines
7.7 KiB
Python
236 lines
7.7 KiB
Python
"""Support for ONVIF Cameras with FFmpeg as decoder."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from haffmpeg.camera import CameraMjpeg
|
|
from onvif.exceptions import ONVIFError
|
|
import voluptuous as vol
|
|
from yarl import URL
|
|
|
|
from homeassistant.components import ffmpeg
|
|
from homeassistant.components.camera import Camera, CameraEntityFeature
|
|
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, get_ffmpeg_manager
|
|
from homeassistant.components.stream import (
|
|
CONF_RTSP_TRANSPORT,
|
|
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
|
RTSP_TRANSPORTS,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import HTTP_BASIC_AUTHENTICATION
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import config_validation as cv, entity_platform
|
|
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .const import (
|
|
ABSOLUTE_MOVE,
|
|
ATTR_CONTINUOUS_DURATION,
|
|
ATTR_DISTANCE,
|
|
ATTR_MOVE_MODE,
|
|
ATTR_PAN,
|
|
ATTR_PRESET,
|
|
ATTR_SPEED,
|
|
ATTR_TILT,
|
|
ATTR_ZOOM,
|
|
CONF_SNAPSHOT_AUTH,
|
|
CONTINUOUS_MOVE,
|
|
DIR_DOWN,
|
|
DIR_LEFT,
|
|
DIR_RIGHT,
|
|
DIR_UP,
|
|
DOMAIN,
|
|
GOTOPRESET_MOVE,
|
|
LOGGER,
|
|
RELATIVE_MOVE,
|
|
SERVICE_PTZ,
|
|
STOP_MOVE,
|
|
ZOOM_IN,
|
|
ZOOM_OUT,
|
|
)
|
|
from .device import ONVIFDevice
|
|
from .entity import ONVIFBaseEntity
|
|
from .models import Profile
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the ONVIF camera video stream."""
|
|
platform = entity_platform.async_get_current_platform()
|
|
|
|
# Create PTZ service
|
|
platform.async_register_entity_service(
|
|
SERVICE_PTZ,
|
|
{
|
|
vol.Optional(ATTR_PAN): vol.In([DIR_LEFT, DIR_RIGHT]),
|
|
vol.Optional(ATTR_TILT): vol.In([DIR_UP, DIR_DOWN]),
|
|
vol.Optional(ATTR_ZOOM): vol.In([ZOOM_OUT, ZOOM_IN]),
|
|
vol.Optional(ATTR_DISTANCE, default=0.1): cv.small_float,
|
|
vol.Optional(ATTR_SPEED, default=0.5): cv.small_float,
|
|
vol.Optional(ATTR_MOVE_MODE, default=RELATIVE_MOVE): vol.In(
|
|
[
|
|
CONTINUOUS_MOVE,
|
|
RELATIVE_MOVE,
|
|
ABSOLUTE_MOVE,
|
|
GOTOPRESET_MOVE,
|
|
STOP_MOVE,
|
|
]
|
|
),
|
|
vol.Optional(ATTR_CONTINUOUS_DURATION, default=0.5): cv.small_float,
|
|
vol.Optional(ATTR_PRESET, default="0"): cv.string,
|
|
},
|
|
"async_perform_ptz",
|
|
)
|
|
|
|
device = hass.data[DOMAIN][config_entry.unique_id]
|
|
async_add_entities(
|
|
[ONVIFCameraEntity(device, profile) for profile in device.profiles]
|
|
)
|
|
|
|
|
|
class ONVIFCameraEntity(ONVIFBaseEntity, Camera):
|
|
"""Representation of an ONVIF camera."""
|
|
|
|
_attr_supported_features = CameraEntityFeature.STREAM
|
|
|
|
def __init__(self, device: ONVIFDevice, profile: Profile) -> None:
|
|
"""Initialize ONVIF camera entity."""
|
|
ONVIFBaseEntity.__init__(self, device)
|
|
Camera.__init__(self)
|
|
self.profile = profile
|
|
self.stream_options[CONF_RTSP_TRANSPORT] = device.config_entry.options.get(
|
|
CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS))
|
|
)
|
|
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
|
|
device.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False)
|
|
)
|
|
self._basic_auth = (
|
|
device.config_entry.data.get(CONF_SNAPSHOT_AUTH)
|
|
== HTTP_BASIC_AUTHENTICATION
|
|
)
|
|
self._stream_uri: str | None = None
|
|
self._stream_uri_future: asyncio.Future[str] | None = None
|
|
self._attr_entity_registry_enabled_default = (
|
|
device.max_resolution == profile.video.resolution.width
|
|
)
|
|
if profile.index:
|
|
self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}"
|
|
else:
|
|
self._attr_unique_id = self.mac_or_serial
|
|
self._attr_name = f"{device.name} {profile.name}"
|
|
|
|
@property
|
|
def use_stream_for_stills(self) -> bool:
|
|
"""Whether or not to use stream to generate stills."""
|
|
return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream)
|
|
|
|
async def stream_source(self):
|
|
"""Return the stream source."""
|
|
return await self._async_get_stream_uri()
|
|
|
|
async def async_camera_image(
|
|
self, width: int | None = None, height: int | None = None
|
|
) -> bytes | None:
|
|
"""Return a still image response from the camera."""
|
|
|
|
if self.device.capabilities.snapshot:
|
|
try:
|
|
if image := await self.device.device.get_snapshot(
|
|
self.profile.token, self._basic_auth
|
|
):
|
|
return image
|
|
except ONVIFError as err:
|
|
LOGGER.error(
|
|
"Fetch snapshot image failed from %s, falling back to FFmpeg; %s",
|
|
self.device.name,
|
|
err,
|
|
)
|
|
else:
|
|
LOGGER.error(
|
|
"Fetch snapshot image failed from %s, falling back to FFmpeg",
|
|
self.device.name,
|
|
)
|
|
|
|
stream_uri = await self._async_get_stream_uri()
|
|
return await ffmpeg.async_get_image(
|
|
self.hass,
|
|
stream_uri,
|
|
extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS),
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
async def handle_async_mjpeg_stream(self, request):
|
|
"""Generate an HTTP MJPEG stream from the camera."""
|
|
LOGGER.debug("Handling mjpeg stream from camera '%s'", self.device.name)
|
|
|
|
ffmpeg_manager = get_ffmpeg_manager(self.hass)
|
|
stream = CameraMjpeg(ffmpeg_manager.binary)
|
|
stream_uri = await self._async_get_stream_uri()
|
|
|
|
await stream.open_camera(
|
|
stream_uri,
|
|
extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS),
|
|
)
|
|
|
|
try:
|
|
stream_reader = await stream.get_reader()
|
|
return await async_aiohttp_proxy_stream(
|
|
self.hass,
|
|
request,
|
|
stream_reader,
|
|
ffmpeg_manager.ffmpeg_stream_content_type,
|
|
)
|
|
finally:
|
|
await stream.close()
|
|
|
|
async def _async_get_stream_uri(self) -> str:
|
|
"""Return the stream URI."""
|
|
if self._stream_uri:
|
|
return self._stream_uri
|
|
if self._stream_uri_future:
|
|
return await self._stream_uri_future
|
|
loop = asyncio.get_running_loop()
|
|
self._stream_uri_future = loop.create_future()
|
|
try:
|
|
uri_no_auth = await self.device.async_get_stream_uri(self.profile)
|
|
except (TimeoutError, Exception) as err:
|
|
LOGGER.error("Failed to get stream uri: %s", err)
|
|
if self._stream_uri_future:
|
|
self._stream_uri_future.set_exception(err)
|
|
raise
|
|
url = URL(uri_no_auth)
|
|
url = url.with_user(self.device.username)
|
|
url = url.with_password(self.device.password)
|
|
self._stream_uri = str(url)
|
|
self._stream_uri_future.set_result(self._stream_uri)
|
|
return self._stream_uri
|
|
|
|
async def async_perform_ptz(
|
|
self,
|
|
distance,
|
|
speed,
|
|
move_mode,
|
|
continuous_duration,
|
|
preset,
|
|
pan=None,
|
|
tilt=None,
|
|
zoom=None,
|
|
) -> None:
|
|
"""Perform a PTZ action on the camera."""
|
|
await self.device.async_perform_ptz(
|
|
self.profile,
|
|
distance,
|
|
speed,
|
|
move_mode,
|
|
continuous_duration,
|
|
preset,
|
|
pan,
|
|
tilt,
|
|
zoom,
|
|
)
|