hass-core/homeassistant/components/onvif/camera.py
uvjustin 407aa31adc
Generate Stream snapshots using next keyframe (#96991)
* Add wait_for_next_keyframe option to stream images
Add STREAM_SNAPSHOT to CameraEntityFeature
Use wait_for_next_keyframe option for snapshots using stream

* Update stream test comments

* Add generic camera snapshot test

* Get stream still images directly in camera
Remove getting stream images from generic, nest, and ONVIF
Refactor camera preferences
Add use_stream_for_stills setting to camera
Update tests

* Only attempt to get stream image if integration supports stream

* Use property instead of entity registry setting

* Split out getting stream prerequisites from stream_source in nest

* Use cached_property for rtsp live stream trait

* Make rtsp live stream trait NestCamera attribute

* Update homeassistant/components/nest/camera.py

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* Change usage of async_timeout

* Change import formatting in generic/test_camera

* Simplify Nest camera property initialization

---------

Co-authored-by: Allen Porter <allen.porter@gmail.com>
2023-08-26 10:39:40 -07:00

243 lines
7.9 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 .base import ONVIFBaseEntity
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 .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
@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)
@property
def name(self) -> str:
"""Return the name of this camera."""
return f"{self.device.name} {self.profile.name}"
@property
def unique_id(self) -> str:
"""Return a unique ID."""
if self.profile.index:
return f"{self.mac_or_serial}_{self.profile.index}"
return self.mac_or_serial
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self.device.max_resolution == self.profile.video.resolution.width
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 (asyncio.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,
)