Use httpx instead of requests for mjpeg camera images that need digest to avoid jump to executor (#93244)

* Use httpx instead of requests for mjpeg camera images that need digest

Avoids jump to executor

* Use httpx instead of requests for mjpeg camera images that need digest

Avoids jump to executor

* stream as well

* fix

* fix
This commit is contained in:
J. Nick Koston 2023-05-27 18:46:46 -05:00 committed by GitHub
parent 3a1389c3b4
commit 5a0b25479e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 64 additions and 34 deletions

View File

@ -16,7 +16,7 @@ __all__ = [
] ]
def setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the MJPEG IP Camera integration.""" """Set up the MJPEG IP Camera integration."""
filter_urllib3_logging() filter_urllib3_logging()
return True return True

View File

@ -2,14 +2,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable from collections.abc import AsyncIterator
from contextlib import closing from contextlib import suppress
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
import async_timeout import async_timeout
import requests import httpx
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
from yarl import URL from yarl import URL
from homeassistant.components.camera import Camera from homeassistant.components.camera import Camera
@ -29,9 +28,13 @@ from homeassistant.helpers.aiohttp_client import (
) )
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
TIMEOUT = 10
BUFFER_SIZE = 102400
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -59,11 +62,11 @@ async def async_setup_entry(
) )
def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None: async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None:
"""Take in a MJPEG stream object, return the jpg from it.""" """Take in a MJPEG stream object, return the jpg from it."""
data = b"" data = b""
for chunk in stream: async for chunk in stream:
data += chunk data += chunk
jpg_end = data.find(b"\xff\xd9") jpg_end = data.find(b"\xff\xd9")
@ -137,12 +140,11 @@ class MjpegCamera(Camera):
self._authentication == HTTP_DIGEST_AUTHENTICATION self._authentication == HTTP_DIGEST_AUTHENTICATION
or self._still_image_url is None or self._still_image_url is None
): ):
image = await self.hass.async_add_executor_job(self.camera_image) return await self._async_digest_camera_image()
return image
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(TIMEOUT):
response = await websession.get(self._still_image_url, auth=self._auth) response = await websession.get(self._still_image_url, auth=self._auth)
image = await response.read() image = await response.read()
@ -156,37 +158,65 @@ class MjpegCamera(Camera):
return None return None
def camera_image( def _get_digest_auth(self) -> httpx.DigestAuth:
self, width: int | None = None, height: int | None = None """Return a DigestAuth object."""
) -> bytes | None: username = "" if self._username is None else self._username
"""Return a still image response from the camera.""" return httpx.DigestAuth(username, self._password)
if self._username and self._password:
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth(
self._username, self._password
)
else:
auth = HTTPBasicAuth(self._username, self._password)
req = requests.get(
self._mjpeg_url,
auth=auth,
stream=True,
timeout=10,
verify=self._verify_ssl,
)
else:
req = requests.get(self._mjpeg_url, stream=True, timeout=10)
with closing(req) as response: async def _async_digest_camera_image(self) -> bytes | None:
return extract_image_from_mjpeg(response.iter_content(102400)) """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_digest_auth()
try:
if self._still_image_url:
# Fallback to MJPEG stream if still image URL is not available
with suppress(asyncio.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 asyncio.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_digest_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(asyncio.TimeoutError, httpx.HTTPError):
async for chunk in stream.aiter_bytes(BUFFER_SIZE):
if not self.hass.is_running:
break
async with async_timeout.timeout(TIMEOUT):
await response.write(chunk)
return response
async def handle_async_mjpeg_stream( async def handle_async_mjpeg_stream(
self, request: web.Request self, request: web.Request
) -> web.StreamResponse | None: ) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera.""" """Generate an HTTP MJPEG stream from the camera."""
# aiohttp don't support DigestAuth -> Fallback # aiohttp don't support DigestAuth so we use httpx
if self._authentication == HTTP_DIGEST_AUTHENTICATION: if self._authentication == HTTP_DIGEST_AUTHENTICATION:
return await super().handle_async_mjpeg_stream(request) return await self._handle_async_mjpeg_digest_stream(request)
# connect to stream # connect to stream
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)