Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Support for IP Cameras."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import AsyncIterator
7 from contextlib import suppress
8 
9 import aiohttp
10 from aiohttp import web
11 import httpx
12 from yarl import URL
13 
14 from homeassistant.components.camera import Camera
15 from homeassistant.config_entries import ConfigEntry
16 from homeassistant.const import (
17  CONF_AUTHENTICATION,
18  CONF_PASSWORD,
19  CONF_USERNAME,
20  CONF_VERIFY_SSL,
21  HTTP_BASIC_AUTHENTICATION,
22  HTTP_DIGEST_AUTHENTICATION,
23 )
24 from homeassistant.core import HomeAssistant
26  async_aiohttp_proxy_web,
27  async_get_clientsession,
28 )
29 from homeassistant.helpers.device_registry import DeviceInfo
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.httpx_client import get_async_client
32 
33 from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
34 
35 TIMEOUT = 10
36 BUFFER_SIZE = 102400
37 
38 
40  hass: HomeAssistant,
41  entry: ConfigEntry,
42  async_add_entities: AddEntitiesCallback,
43 ) -> None:
44  """Set up a MJPEG IP Camera based on a config entry."""
46  [
48  name=entry.title,
49  authentication=entry.options[CONF_AUTHENTICATION],
50  username=entry.options.get(CONF_USERNAME),
51  password=entry.options[CONF_PASSWORD],
52  mjpeg_url=entry.options[CONF_MJPEG_URL],
53  still_image_url=entry.options.get(CONF_STILL_IMAGE_URL),
54  verify_ssl=entry.options[CONF_VERIFY_SSL],
55  unique_id=entry.entry_id,
56  device_info=DeviceInfo(
57  name=entry.title,
58  identifiers={(DOMAIN, entry.entry_id)},
59  ),
60  )
61  ]
62  )
63 
64 
65 async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None:
66  """Take in a MJPEG stream object, return the jpg from it."""
67  data = b""
68 
69  async for chunk in stream:
70  data += chunk
71  jpg_end = data.find(b"\xff\xd9")
72 
73  if jpg_end == -1:
74  continue
75 
76  jpg_start = data.find(b"\xff\xd8")
77 
78  if jpg_start == -1:
79  continue
80 
81  return data[jpg_start : jpg_end + 2]
82 
83  return None
84 
85 
87  """An implementation of an IP camera that is reachable over a URL."""
88 
89  def __init__(
90  self,
91  *,
92  name: str | None = None,
93  mjpeg_url: str,
94  still_image_url: str | None,
95  authentication: str | None = None,
96  username: str | None = None,
97  password: str = "",
98  verify_ssl: bool = True,
99  unique_id: str | None = None,
100  device_info: DeviceInfo | None = None,
101  ) -> None:
102  """Initialize a MJPEG camera."""
103  super().__init__()
104  self._attr_name_attr_name = name
105  self._authentication_authentication = authentication
106  self._username_username = username
107  self._password_password = password
108  self._mjpeg_url_mjpeg_url = mjpeg_url
109  self._still_image_url_still_image_url = still_image_url
110 
111  self._auth_auth = None
112  if (
113  self._username_username
114  and self._password_password
115  and self._authentication_authentication == HTTP_BASIC_AUTHENTICATION
116  ):
117  self._auth_auth = aiohttp.BasicAuth(self._username_username, password=self._password_password)
118  self._verify_ssl_verify_ssl = verify_ssl
119 
120  if unique_id is not None:
121  self._attr_unique_id_attr_unique_id = unique_id
122  if device_info is not None:
123  self._attr_device_info_attr_device_info = device_info
124 
125  async def stream_source(self) -> str:
126  """Return the stream source."""
127  url = URL(self._mjpeg_url_mjpeg_url)
128  if self._username_username:
129  url = url.with_user(self._username_username)
130  if self._password_password:
131  url = url.with_password(self._password_password)
132  return str(url)
133 
135  self, width: int | None = None, height: int | None = None
136  ) -> bytes | None:
137  """Return a still image response from the camera."""
138  if (
139  self._authentication_authentication == HTTP_DIGEST_AUTHENTICATION
140  or self._still_image_url_still_image_url is None
141  ):
142  return await self._async_digest_or_fallback_camera_image_async_digest_or_fallback_camera_image()
143 
144  websession = async_get_clientsession(self.hasshass, verify_ssl=self._verify_ssl_verify_ssl)
145  try:
146  async with asyncio.timeout(TIMEOUT):
147  response = await websession.get(self._still_image_url_still_image_url, auth=self._auth_auth)
148 
149  return await response.read()
150 
151  except TimeoutError:
152  LOGGER.error("Timeout getting camera image from %s", self.namename)
153 
154  except aiohttp.ClientError as err:
155  LOGGER.error("Error getting new camera image from %s: %s", self.namename, err)
156 
157  return None
158 
159  def _get_httpx_auth(self) -> httpx.Auth:
160  """Return a httpx auth object."""
161  username = "" if self._username_username is None else self._username_username
162  digest_auth = self._authentication_authentication == HTTP_DIGEST_AUTHENTICATION
163  cls = httpx.DigestAuth if digest_auth else httpx.BasicAuth
164  return cls(username, self._password_password)
165 
166  async def _async_digest_or_fallback_camera_image(self) -> bytes | None:
167  """Return a still image response from the camera using digest authentication."""
168  client = get_async_client(self.hasshass, verify_ssl=self._verify_ssl_verify_ssl)
169  auth = self._get_httpx_auth_get_httpx_auth()
170  try:
171  if self._still_image_url_still_image_url:
172  # Fallback to MJPEG stream if still image URL is not available
173  with suppress(TimeoutError, httpx.HTTPError):
174  return (
175  await client.get(
176  self._still_image_url_still_image_url, auth=auth, timeout=TIMEOUT
177  )
178  ).content
179 
180  async with client.stream(
181  "get", self._mjpeg_url_mjpeg_url, auth=auth, timeout=TIMEOUT
182  ) as stream:
183  return await async_extract_image_from_mjpeg(
184  stream.aiter_bytes(BUFFER_SIZE)
185  )
186 
187  except TimeoutError:
188  LOGGER.error("Timeout getting camera image from %s", self.namename)
189 
190  except httpx.HTTPError as err:
191  LOGGER.error("Error getting new camera image from %s: %s", self.namename, err)
192 
193  return None
194 
196  self, request: web.Request
197  ) -> web.StreamResponse | None:
198  """Generate an HTTP MJPEG stream from the camera using digest authentication."""
199  async with get_async_client(self.hasshass, verify_ssl=self._verify_ssl_verify_ssl).stream(
200  "get", self._mjpeg_url_mjpeg_url, auth=self._get_httpx_auth_get_httpx_auth(), timeout=TIMEOUT
201  ) as stream:
202  response = web.StreamResponse(headers=stream.headers)
203  await response.prepare(request)
204  # Stream until we are done or client disconnects
205  with suppress(TimeoutError, httpx.HTTPError):
206  async for chunk in stream.aiter_bytes(BUFFER_SIZE):
207  if not self.hasshass.is_running:
208  break
209  async with asyncio.timeout(TIMEOUT):
210  await response.write(chunk)
211  return response
212 
214  self, request: web.Request
215  ) -> web.StreamResponse | None:
216  """Generate an HTTP MJPEG stream from the camera."""
217  # aiohttp don't support DigestAuth so we use httpx
218  if self._authentication_authentication == HTTP_DIGEST_AUTHENTICATION:
219  return await self._handle_async_mjpeg_digest_stream_handle_async_mjpeg_digest_stream(request)
220 
221  # connect to stream
222  websession = async_get_clientsession(self.hasshass, verify_ssl=self._verify_ssl_verify_ssl)
223  stream_coro = websession.get(self._mjpeg_url_mjpeg_url, auth=self._auth_auth)
224 
225  return await async_aiohttp_proxy_web(self.hasshass, request, stream_coro)
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:136
web.StreamResponse|None _handle_async_mjpeg_digest_stream(self, web.Request request)
Definition: camera.py:197
web.StreamResponse|None handle_async_mjpeg_stream(self, web.Request request)
Definition: camera.py:215
None __init__(self, *str|None name=None, str mjpeg_url, str|None still_image_url, str|None authentication=None, str|None username=None, str password="", bool verify_ssl=True, str|None unique_id=None, DeviceInfo|None device_info=None)
Definition: camera.py:101
str|UndefinedType|None name(self)
Definition: entity.py:738
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: camera.py:43
bytes|None async_extract_image_from_mjpeg(AsyncIterator[bytes] stream)
Definition: camera.py:65
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
web.StreamResponse|None async_aiohttp_proxy_web(HomeAssistant hass, web.BaseRequest request, Awaitable[aiohttp.ClientResponse] web_coro, int buffer_size=102400, int timeout=10)
httpx.AsyncClient get_async_client(HomeAssistant hass, bool verify_ssl=True)
Definition: httpx_client.py:41