core/homeassistant/components/mjpeg/camera.py

226 lines
7.7 KiB
Python

"""Support for IP Cameras."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import suppress
import aiohttp
from aiohttp import web
import httpx
from yarl import URL
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_web,
async_get_clientsession,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
TIMEOUT = 10
BUFFER_SIZE = 102400
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a MJPEG IP Camera based on a config entry."""
async_add_entities(
[
MjpegCamera(
name=entry.title,
authentication=entry.options[CONF_AUTHENTICATION],
username=entry.options.get(CONF_USERNAME),
password=entry.options[CONF_PASSWORD],
mjpeg_url=entry.options[CONF_MJPEG_URL],
still_image_url=entry.options.get(CONF_STILL_IMAGE_URL),
verify_ssl=entry.options[CONF_VERIFY_SSL],
unique_id=entry.entry_id,
device_info=DeviceInfo(
name=entry.title,
identifiers={(DOMAIN, entry.entry_id)},
),
)
]
)
async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None:
"""Take in a MJPEG stream object, return the jpg from it."""
data = b""
async for chunk in stream:
data += chunk
jpg_end = data.find(b"\xff\xd9")
if jpg_end == -1:
continue
jpg_start = data.find(b"\xff\xd8")
if jpg_start == -1:
continue
return data[jpg_start : jpg_end + 2]
return None
class MjpegCamera(Camera):
"""An implementation of an IP camera that is reachable over a URL."""
def __init__(
self,
*,
name: str | None = None,
mjpeg_url: str,
still_image_url: str | None,
authentication: str | None = None,
username: str | None = None,
password: str = "",
verify_ssl: bool = True,
unique_id: str | None = None,
device_info: DeviceInfo | None = None,
) -> None:
"""Initialize a MJPEG camera."""
super().__init__()
self._attr_name = name
self._authentication = authentication
self._username = username
self._password = password
self._mjpeg_url = mjpeg_url
self._still_image_url = still_image_url
self._auth = None
if (
self._username
and self._password
and self._authentication == HTTP_BASIC_AUTHENTICATION
):
self._auth = aiohttp.BasicAuth(self._username, password=self._password)
self._verify_ssl = verify_ssl
if unique_id is not None:
self._attr_unique_id = unique_id
if device_info is not None:
self._attr_device_info = device_info
async def stream_source(self) -> str:
"""Return the stream source."""
url = URL(self._mjpeg_url)
if self._username:
url = url.with_user(self._username)
if self._password:
url = url.with_password(self._password)
return str(url)
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._authentication == HTTP_DIGEST_AUTHENTICATION
or self._still_image_url is None
):
return await self._async_digest_or_fallback_camera_image()
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
try:
async with asyncio.timeout(TIMEOUT):
response = await websession.get(self._still_image_url, auth=self._auth)
return await response.read()
except TimeoutError:
LOGGER.error("Timeout getting camera image from %s", self.name)
except aiohttp.ClientError as err:
LOGGER.error("Error getting new camera image from %s: %s", self.name, err)
return None
def _get_httpx_auth(self) -> httpx.Auth:
"""Return a httpx auth object."""
username = "" if self._username is None else self._username
digest_auth = self._authentication == HTTP_DIGEST_AUTHENTICATION
cls = httpx.DigestAuth if digest_auth else httpx.BasicAuth
return cls(username, self._password)
async def _async_digest_or_fallback_camera_image(self) -> bytes | None:
"""Return a still image response from the camera using digest authentication."""
client = get_async_client(self.hass, verify_ssl=self._verify_ssl)
auth = self._get_httpx_auth()
try:
if self._still_image_url:
# Fallback to MJPEG stream if still image URL is not available
with suppress(TimeoutError, httpx.HTTPError):
return (
await client.get(
self._still_image_url, auth=auth, timeout=TIMEOUT
)
).content
async with client.stream(
"get", self._mjpeg_url, auth=auth, timeout=TIMEOUT
) as stream:
return await async_extract_image_from_mjpeg(
stream.aiter_bytes(BUFFER_SIZE)
)
except TimeoutError:
LOGGER.error("Timeout getting camera image from %s", self.name)
except httpx.HTTPError as err:
LOGGER.error("Error getting new camera image from %s: %s", self.name, err)
return None
async def _handle_async_mjpeg_digest_stream(
self, request: web.Request
) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera using digest authentication."""
async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream(
"get", self._mjpeg_url, auth=self._get_httpx_auth(), timeout=TIMEOUT
) as stream:
response = web.StreamResponse(headers=stream.headers)
await response.prepare(request)
# Stream until we are done or client disconnects
with suppress(TimeoutError, httpx.HTTPError):
async for chunk in stream.aiter_bytes(BUFFER_SIZE):
if not self.hass.is_running:
break
async with asyncio.timeout(TIMEOUT):
await response.write(chunk)
return response
async def handle_async_mjpeg_stream(
self, request: web.Request
) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera."""
# aiohttp don't support DigestAuth so we use httpx
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
return await self._handle_async_mjpeg_digest_stream(request)
# connect to stream
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
stream_coro = websession.get(self._mjpeg_url, auth=self._auth)
return await async_aiohttp_proxy_web(self.hass, request, stream_coro)