Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Proxy camera platform that enables image processing of camera data."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 import io
8 import logging
9 
10 from PIL import Image
11 import voluptuous as vol
12 
14  PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA,
15  Camera,
16  async_get_image,
17  async_get_mjpeg_stream,
18  async_get_still_stream,
19 )
20 from homeassistant.const import CONF_ENTITY_ID, CONF_MODE, CONF_NAME
21 from homeassistant.core import HomeAssistant
22 from homeassistant.exceptions import HomeAssistantError
23 from homeassistant.helpers import config_validation as cv
24 from homeassistant.helpers.entity_platform import AddEntitiesCallback
25 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
26 import homeassistant.util.dt as dt_util
27 
28 _LOGGER = logging.getLogger(__name__)
29 
30 CONF_CACHE_IMAGES = "cache_images"
31 CONF_FORCE_RESIZE = "force_resize"
32 CONF_IMAGE_QUALITY = "image_quality"
33 CONF_IMAGE_REFRESH_RATE = "image_refresh_rate"
34 CONF_MAX_IMAGE_WIDTH = "max_image_width"
35 CONF_MAX_IMAGE_HEIGHT = "max_image_height"
36 CONF_MAX_STREAM_WIDTH = "max_stream_width"
37 CONF_MAX_STREAM_HEIGHT = "max_stream_height"
38 CONF_IMAGE_TOP = "image_top"
39 CONF_IMAGE_LEFT = "image_left"
40 CONF_STREAM_QUALITY = "stream_quality"
41 
42 MODE_RESIZE = "resize"
43 MODE_CROP = "crop"
44 
45 DEFAULT_BASENAME = "Camera Proxy"
46 DEFAULT_QUALITY = 75
47 
48 PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend(
49  {
50  vol.Required(CONF_ENTITY_ID): cv.entity_id,
51  vol.Optional(CONF_NAME): cv.string,
52  vol.Optional(CONF_CACHE_IMAGES, default=False): cv.boolean,
53  vol.Optional(CONF_FORCE_RESIZE, default=False): cv.boolean,
54  vol.Optional(CONF_MODE, default=MODE_RESIZE): vol.In([MODE_RESIZE, MODE_CROP]),
55  vol.Optional(CONF_IMAGE_QUALITY): int,
56  vol.Optional(CONF_IMAGE_REFRESH_RATE): float,
57  vol.Optional(CONF_MAX_IMAGE_WIDTH): int,
58  vol.Optional(CONF_MAX_IMAGE_HEIGHT): int,
59  vol.Optional(CONF_MAX_STREAM_WIDTH): int,
60  vol.Optional(CONF_MAX_STREAM_HEIGHT): int,
61  vol.Optional(CONF_IMAGE_LEFT): int,
62  vol.Optional(CONF_IMAGE_TOP): int,
63  vol.Optional(CONF_STREAM_QUALITY): int,
64  }
65 )
66 
67 
69  hass: HomeAssistant,
70  config: ConfigType,
71  async_add_entities: AddEntitiesCallback,
72  discovery_info: DiscoveryInfoType | None = None,
73 ) -> None:
74  """Set up the Proxy camera platform."""
75  async_add_entities([ProxyCamera(hass, config)])
76 
77 
78 def _precheck_image(image, opts):
79  """Perform some pre-checks on the given image."""
80  if not opts:
81  raise ValueError
82  try:
83  img = Image.open(io.BytesIO(image))
84  except OSError as err:
85  _LOGGER.warning("Failed to open image")
86  raise ValueError from err
87  imgfmt = str(img.format)
88  if imgfmt not in ("PNG", "JPEG"):
89  _LOGGER.warning("Image is of unsupported type: %s", imgfmt)
90  raise ValueError
91  if img.mode != "RGB":
92  img = img.convert("RGB")
93  return img
94 
95 
96 def _resize_image(image, opts):
97  """Resize image."""
98  try:
99  img = _precheck_image(image, opts)
100  except ValueError:
101  return image
102 
103  quality = opts.quality or DEFAULT_QUALITY
104  new_width = opts.max_width
105  (old_width, old_height) = img.size
106  old_size = len(image)
107  if old_width <= new_width:
108  if opts.quality is None:
109  _LOGGER.debug("Image is smaller-than/equal-to requested width")
110  return image
111  new_width = old_width
112 
113  scale = new_width / float(old_width)
114  new_height = int(float(old_height) * float(scale))
115 
116  img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
117  imgbuf = io.BytesIO()
118  img.save(imgbuf, "JPEG", optimize=True, quality=quality)
119  newimage = imgbuf.getvalue()
120  if not opts.force_resize and len(newimage) >= old_size:
121  _LOGGER.debug(
122  (
123  "Using original image (%d bytes) "
124  "because resized image (%d bytes) is not smaller"
125  ),
126  old_size,
127  len(newimage),
128  )
129  return image
130 
131  _LOGGER.debug(
132  "Resized image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
133  old_width,
134  old_height,
135  old_size,
136  new_width,
137  new_height,
138  len(newimage),
139  )
140  return newimage
141 
142 
143 def _crop_image(image, opts):
144  """Crop image."""
145  try:
146  img = _precheck_image(image, opts)
147  except ValueError:
148  return image
149 
150  quality = opts.quality or DEFAULT_QUALITY
151  (old_width, old_height) = img.size
152  old_size = len(image)
153  if opts.top is None:
154  opts.top = 0
155  if opts.left is None:
156  opts.left = 0
157  if opts.max_width is None or opts.max_width > old_width - opts.left:
158  opts.max_width = old_width - opts.left
159  if opts.max_height is None or opts.max_height > old_height - opts.top:
160  opts.max_height = old_height - opts.top
161 
162  img = img.crop(
163  (opts.left, opts.top, opts.left + opts.max_width, opts.top + opts.max_height)
164  )
165  imgbuf = io.BytesIO()
166  img.save(imgbuf, "JPEG", optimize=True, quality=quality)
167  newimage = imgbuf.getvalue()
168 
169  _LOGGER.debug(
170  "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
171  old_width,
172  old_height,
173  old_size,
174  opts.max_width,
175  opts.max_height,
176  len(newimage),
177  )
178  return newimage
179 
180 
181 class ImageOpts:
182  """The representation of image options."""
183 
184  def __init__(self, max_width, max_height, left, top, quality, force_resize):
185  """Initialize image options."""
186  self.max_widthmax_width = max_width
187  self.max_heightmax_height = max_height
188  self.leftleft = left
189  self.toptop = top
190  self.qualityquality = quality
191  self.force_resizeforce_resize = force_resize
192 
193  def __bool__(self):
194  """Bool evaluation rules."""
195  return bool(self.max_widthmax_width or self.qualityquality)
196 
197 
199  """The representation of a Proxy camera."""
200 
201  def __init__(self, hass, config):
202  """Initialize a proxy camera component."""
203  super().__init__()
204  self.hasshasshass = hass
205  self._proxied_camera_proxied_camera = config.get(CONF_ENTITY_ID)
206  self._name_name = (
207  config.get(CONF_NAME) or f"{DEFAULT_BASENAME} - {self._proxied_camera}"
208  )
209  self._image_opts_image_opts = ImageOpts(
210  config.get(CONF_MAX_IMAGE_WIDTH),
211  config.get(CONF_MAX_IMAGE_HEIGHT),
212  config.get(CONF_IMAGE_LEFT),
213  config.get(CONF_IMAGE_TOP),
214  config.get(CONF_IMAGE_QUALITY),
215  config.get(CONF_FORCE_RESIZE),
216  )
217 
218  self._stream_opts_stream_opts = ImageOpts(
219  config.get(CONF_MAX_STREAM_WIDTH),
220  config.get(CONF_MAX_STREAM_HEIGHT),
221  config.get(CONF_IMAGE_LEFT),
222  config.get(CONF_IMAGE_TOP),
223  config.get(CONF_STREAM_QUALITY),
224  True,
225  )
226 
227  self._image_refresh_rate_image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE)
228  self._cache_images_cache_images = bool(
229  config.get(CONF_IMAGE_REFRESH_RATE) or config.get(CONF_CACHE_IMAGES)
230  )
231  self._last_image_time_last_image_time = dt_util.utc_from_timestamp(0)
232  self._last_image_last_image = None
233  self._mode_mode = config.get(CONF_MODE)
234 
236  self, width: int | None = None, height: int | None = None
237  ) -> bytes | None:
238  """Return camera image."""
239  return asyncio.run_coroutine_threadsafe(
240  self.async_camera_imageasync_camera_imageasync_camera_image(), self.hasshasshass.loop
241  ).result()
242 
244  self, width: int | None = None, height: int | None = None
245  ) -> bytes | None:
246  """Return a still image response from the camera."""
247  now = dt_util.utcnow()
248 
249  if self._image_refresh_rate_image_refresh_rate and now < self._last_image_time_last_image_time + timedelta(
250  seconds=self._image_refresh_rate_image_refresh_rate
251  ):
252  return self._last_image_last_image
253 
254  self._last_image_time_last_image_time = now
255  image = await async_get_image(self.hasshasshass, self._proxied_camera_proxied_camera)
256  if not image:
257  _LOGGER.error("Error getting original camera image")
258  return self._last_image_last_image
259 
260  if self._mode_mode == MODE_RESIZE:
261  job = _resize_image
262  else:
263  job = _crop_image
264  image_bytes: bytes = await self.hasshasshass.async_add_executor_job(
265  job, image.content, self._image_opts_image_opts
266  )
267 
268  if self._cache_images_cache_images:
269  self._last_image_last_image = image_bytes
270  return image_bytes
271 
272  async def handle_async_mjpeg_stream(self, request):
273  """Generate an HTTP MJPEG stream from camera images."""
274  if not self._stream_opts_stream_opts:
275  return await async_get_mjpeg_stream(
276  self.hasshasshass, request, self._proxied_camera_proxied_camera
277  )
278 
279  return await async_get_still_stream(
280  request, self._async_stream_image_async_stream_image, self.content_type, self.frame_intervalframe_interval
281  )
282 
283  @property
284  def name(self):
285  """Return the name of this camera."""
286  return self._name_name
287 
288  async def _async_stream_image(self):
289  """Return a still image response from the camera."""
290  try:
291  image = await async_get_image(self.hasshasshass, self._proxied_camera_proxied_camera)
292  if not image:
293  return None
294  except HomeAssistantError as err:
295  raise asyncio.CancelledError from err
296 
297  if self._mode_mode == MODE_RESIZE:
298  job = _resize_image
299  else:
300  job = _crop_image
301  return await self.hasshasshass.async_add_executor_job(
302  job, image.content, self._stream_opts_stream_opts
303  )
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: __init__.py:726
def __init__(self, max_width, max_height, left, top, quality, force_resize)
Definition: camera.py:184
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:245
bytes|None camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:237
Image async_get_image(HomeAssistant hass, str entity_id, int timeout=10, int|None width=None, int|None height=None)
Definition: __init__.py:245
web.StreamResponse|None async_get_mjpeg_stream(HomeAssistant hass, web.Request request, str entity_id)
Definition: __init__.py:279
web.StreamResponse async_get_still_stream(web.Request request, Callable[[], Awaitable[bytes|None]] image_cb, str content_type, float interval)
Definition: __init__.py:296
def _precheck_image(image, opts)
Definition: camera.py:78
def _resize_image(image, opts)
Definition: camera.py:96
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: camera.py:73