Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The image integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import collections
7 from contextlib import suppress
8 from dataclasses import dataclass
9 from datetime import datetime, timedelta
10 import logging
11 import os
12 from random import SystemRandom
13 from typing import Final, final
14 
15 from aiohttp import hdrs, web
16 import httpx
17 from propcache import cached_property
18 import voluptuous as vol
19 
20 from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
23 from homeassistant.core import (
24  Event,
25  EventStateChangedData,
26  HomeAssistant,
27  ServiceCall,
28  callback,
29 )
30 from homeassistant.exceptions import HomeAssistantError
32 from homeassistant.helpers.entity import Entity, EntityDescription
33 from homeassistant.helpers.entity_component import EntityComponent
34 from homeassistant.helpers.event import (
35  async_track_state_change_event,
36  async_track_time_interval,
37 )
38 from homeassistant.helpers.httpx_client import get_async_client
39 from homeassistant.helpers.typing import (
40  UNDEFINED,
41  ConfigType,
42  UndefinedType,
43  VolDictType,
44 )
45 
46 from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT
47 
48 _LOGGER = logging.getLogger(__name__)
49 
50 SERVICE_SNAPSHOT: Final = "snapshot"
51 
52 ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
53 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
54 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
55 SCAN_INTERVAL: Final = timedelta(seconds=30)
56 
57 ATTR_FILENAME: Final = "filename"
58 
59 DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
60 ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}"
61 
62 TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5)
63 _RND: Final = SystemRandom()
64 
65 GET_IMAGE_TIMEOUT: Final = 10
66 
67 FRAME_BOUNDARY = "frame-boundary"
68 FRAME_SEPARATOR = bytes(f"\r\n--{FRAME_BOUNDARY}\r\n", "utf-8")
69 LAST_FRAME_MARKER = bytes(f"\r\n--{FRAME_BOUNDARY}--\r\n", "utf-8")
70 
71 IMAGE_SERVICE_SNAPSHOT: VolDictType = {vol.Required(ATTR_FILENAME): cv.string}
72 
73 
74 class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
75  """A class that describes image entities."""
76 
77 
78 @dataclass
79 class Image:
80  """Represent an image."""
81 
82  content_type: str
83  content: bytes
84 
85 
87  """Error with the content type while loading an image."""
88 
89 
90 def valid_image_content_type(content_type: str | None) -> str:
91  """Validate the assigned content type is one of an image."""
92  if content_type is None or content_type.split("/", 1)[0].lower() != "image":
93  raise ImageContentTypeError
94  return content_type
95 
96 
97 async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
98  """Fetch image from an image entity."""
99  with suppress(asyncio.CancelledError, TimeoutError, ImageContentTypeError):
100  async with asyncio.timeout(timeout):
101  if image_bytes := await image_entity.async_image():
102  content_type = valid_image_content_type(image_entity.content_type)
103  return Image(content_type, image_bytes)
104 
105  raise HomeAssistantError("Unable to get image")
106 
107 
108 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
109  """Set up the image component."""
110  component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity](
111  _LOGGER, DOMAIN, hass, SCAN_INTERVAL
112  )
113 
114  hass.http.register_view(ImageView(component))
115  hass.http.register_view(ImageStreamView(component))
116 
117  await component.async_setup(config)
118 
119  @callback
120  def update_tokens(time: datetime) -> None:
121  """Update tokens of the entities."""
122  for entity in component.entities:
123  entity.async_update_token()
124  entity.async_write_ha_state()
125 
127  hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Image update tokens"
128  )
129 
130  @callback
131  def unsub_track_time_interval(_event: Event) -> None:
132  """Unsubscribe track time interval timer."""
133  unsub()
134 
135  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
136 
137  component.async_register_entity_service(
138  SERVICE_SNAPSHOT, IMAGE_SERVICE_SNAPSHOT, async_handle_snapshot_service
139  )
140 
141  return True
142 
143 
144 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
145  """Set up a config entry."""
146  return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
147 
148 
149 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
150  """Unload a config entry."""
151  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
152 
153 
154 CACHED_PROPERTIES_WITH_ATTR_ = {
155  "content_type",
156  "image_last_updated",
157  "image_url",
158 }
159 
160 
161 class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
162  """The base class for image entities."""
163 
164  _entity_component_unrecorded_attributes = frozenset(
165  {"access_token", "entity_picture"}
166  )
167 
168  # Entity Properties
169  _attr_content_type: str = DEFAULT_CONTENT_TYPE
170  _attr_image_last_updated: datetime | None = None
171  _attr_image_url: str | None | UndefinedType = UNDEFINED
172  _attr_should_poll: bool = False # No need to poll image entities
173  _attr_state: None = None # State is determined by last_updated
174  _cached_image: Image | None = None
175 
176  def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None:
177  """Initialize an image entity."""
178  self._client_client = get_async_client(hass, verify_ssl=verify_ssl)
179  self.access_tokens: collections.deque = collections.deque([], 2)
180  self.async_update_tokenasync_update_token()
181 
182  @cached_property
183  def content_type(self) -> str:
184  """Image content type."""
185  return self._attr_content_type_attr_content_type
186 
187  @property
188  def entity_picture(self) -> str | None:
189  """Return a link to the image as entity picture."""
190  if self._attr_entity_picture is not None:
191  return self._attr_entity_picture
192  return ENTITY_IMAGE_URL.format(self.entity_identity_id, self.access_tokens[-1])
193 
194  @cached_property
195  def image_last_updated(self) -> datetime | None:
196  """Time the image was last updated."""
197  return self._attr_image_last_updated
198 
199  @cached_property
200  def image_url(self) -> str | None | UndefinedType:
201  """Return URL of image."""
202  return self._attr_image_url
203 
204  def image(self) -> bytes | None:
205  """Return bytes of image."""
206  raise NotImplementedError
207 
208  async def _fetch_url(self, url: str) -> httpx.Response | None:
209  """Fetch a URL."""
210  try:
211  response = await self._client_client.get(
212  url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True
213  )
214  response.raise_for_status()
215  except httpx.TimeoutException:
216  _LOGGER.error("%s: Timeout getting image from %s", self.entity_identity_id, url)
217  return None
218  except (httpx.RequestError, httpx.HTTPStatusError) as err:
219  _LOGGER.error(
220  "%s: Error getting new image from %s: %s",
221  self.entity_identity_id,
222  url,
223  err,
224  )
225  return None
226  return response
227 
228  async def _async_load_image_from_url(self, url: str) -> Image | None:
229  """Load an image by url."""
230  if response := await self._fetch_url_fetch_url(url):
231  content_type = response.headers.get("content-type")
232  try:
233  return Image(
234  content=response.content,
235  content_type=valid_image_content_type(content_type),
236  )
237  except ImageContentTypeError:
238  _LOGGER.error(
239  "%s: Image from %s has invalid content type: %s",
240  self.entity_identity_id,
241  url,
242  content_type,
243  )
244  return None
245  return None
246 
247  async def async_image(self) -> bytes | None:
248  """Return bytes of image."""
249 
250  if self._cached_image_cached_image:
251  return self._cached_image_cached_image.content
252  if (url := self.image_urlimage_url) is not UNDEFINED:
253  if not url or (image := await self._async_load_image_from_url_async_load_image_from_url(url)) is None:
254  return None
255  self._cached_image_cached_image = image
256  self._attr_content_type_attr_content_type = image.content_type
257  return image.content
258  return await self.hasshass.async_add_executor_job(self.imageimage)
259 
260  @property
261  @final
262  def state(self) -> str | None:
263  """Return the state."""
264  if self.image_last_updatedimage_last_updated is None:
265  return None
266  return self.image_last_updatedimage_last_updated.isoformat()
267 
268  @final
269  @property
270  def state_attributes(self) -> dict[str, str | None]:
271  """Return the state attributes."""
272  return {"access_token": self.access_tokens[-1]}
273 
274  @callback
275  def async_update_token(self) -> None:
276  """Update the used token."""
277  self.access_tokens.append(hex(_RND.getrandbits(256))[2:])
278 
279 
280 class ImageView(HomeAssistantView):
281  """View to serve an image."""
282 
283  name = "api:image:image"
284  requires_auth = False
285  url = "/api/image_proxy/{entity_id}"
286 
287  def __init__(self, component: EntityComponent[ImageEntity]) -> None:
288  """Initialize an image view."""
289  self.componentcomponent = component
290 
291  async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
292  """Start a GET request."""
293  if (image_entity := self.componentcomponent.get_entity(entity_id)) is None:
294  raise web.HTTPNotFound
295 
296  authenticated = (
297  request[KEY_AUTHENTICATED]
298  or request.query.get("token") in image_entity.access_tokens
299  )
300 
301  if not authenticated:
302  # Attempt with invalid bearer token, raise unauthorized
303  # so ban middleware can handle it.
304  if hdrs.AUTHORIZATION in request.headers:
305  raise web.HTTPUnauthorized
306  # Invalid sigAuth or image entity access token
307  raise web.HTTPForbidden
308 
309  return await self.handlehandle(request, image_entity)
310 
311  async def handle(
312  self, request: web.Request, image_entity: ImageEntity
313  ) -> web.StreamResponse:
314  """Serve image."""
315  try:
316  image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
317  except (HomeAssistantError, ValueError) as ex:
318  raise web.HTTPInternalServerError from ex
319 
320  return web.Response(body=image.content, content_type=image.content_type)
321 
322 
324  request: web.Request,
325  image_entity: ImageEntity,
326 ) -> web.StreamResponse:
327  """Generate an HTTP multipart stream from the Image."""
328  response = web.StreamResponse()
329  response.content_type = CONTENT_TYPE_MULTIPART.format(FRAME_BOUNDARY)
330  await response.prepare(request)
331 
332  async def _write_frame() -> bool:
333  img_bytes = await image_entity.async_image()
334  if img_bytes is None:
335  await response.write(LAST_FRAME_MARKER)
336  return False
337  frame = bytearray(FRAME_SEPARATOR)
338  header = bytes(
339  f"Content-Type: {image_entity.content_type}\r\n"
340  f"Content-Length: {len(img_bytes)}\r\n\r\n",
341  "utf-8",
342  )
343  frame.extend(header)
344  frame.extend(img_bytes)
345  # Chrome shows the n-1 frame so send the frame twice
346  # https://issues.chromium.org/issues/41199053
347  # https://issues.chromium.org/issues/40791855
348  # While this results in additional bandwidth usage,
349  # given the low frequency of image updates, it is acceptable.
350  frame.extend(frame)
351  await response.write(frame)
352  return True
353 
354  event = asyncio.Event()
355  timed_out = False
356 
357  @callback
358  def _async_image_state_update(_event: Event[EventStateChangedData]) -> None:
359  """Write image to stream."""
360  event.set()
361 
362  @callback
363  def _async_timeout_reached() -> None:
364  """Handle timeout."""
365  nonlocal timed_out
366  timed_out = True
367  event.set()
368 
369  hass = request.app[KEY_HASS]
370  loop = hass.loop
372  hass,
373  image_entity.entity_id,
374  _async_image_state_update,
375  )
376  timeout_handle = None
377  try:
378  while True:
379  if not await _write_frame():
380  return response
381  # Ensure that an image is sent at least every 55 seconds
382  # Otherwise some devices go blank
383  timeout_handle = loop.call_later(55, _async_timeout_reached)
384  await event.wait()
385  event.clear()
386  if not timed_out:
387  timeout_handle.cancel()
388  timed_out = False
389  finally:
390  if timeout_handle:
391  timeout_handle.cancel()
392  remove()
393 
394 
396  """Image View to serve an multipart stream."""
397 
398  url = "/api/image_proxy_stream/{entity_id}"
399  name = "api:image:stream"
400 
401  async def handle(
402  self, request: web.Request, image_entity: ImageEntity
403  ) -> web.StreamResponse:
404  """Serve image stream."""
405  return await async_get_still_stream(request, image_entity)
406 
407 
409  image: ImageEntity, service_call: ServiceCall
410 ) -> None:
411  """Handle snapshot services calls."""
412  hass = image.hass
413  snapshot_file: str = service_call.data[ATTR_FILENAME]
414 
415  # check if we allow to access to that file
416  if not hass.config.is_allowed_path(snapshot_file):
417  raise HomeAssistantError(
418  f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
419  )
420 
421  async with asyncio.timeout(IMAGE_TIMEOUT):
422  image_data = await image.async_image()
423 
424  if image_data is None:
425  return
426 
427  def _write_image(to_file: str, image_data: bytes) -> None:
428  """Executor helper to write image."""
429  os.makedirs(os.path.dirname(to_file), exist_ok=True)
430  with open(to_file, "wb") as img_file:
431  img_file.write(image_data)
432 
433  try:
434  await hass.async_add_executor_job(_write_image, snapshot_file, image_data)
435  except OSError as err:
436  raise HomeAssistantError("Can't write image to file") from err
Image|None _async_load_image_from_url(self, str url)
Definition: __init__.py:228
dict[str, str|None] state_attributes(self)
Definition: __init__.py:270
None __init__(self, HomeAssistant hass, bool verify_ssl=False)
Definition: __init__.py:176
httpx.Response|None _fetch_url(self, str url)
Definition: __init__.py:208
str|None|UndefinedType image_url(self)
Definition: __init__.py:200
web.StreamResponse handle(self, web.Request request, ImageEntity image_entity)
Definition: __init__.py:403
web.StreamResponse handle(self, web.Request request, ImageEntity image_entity)
Definition: __init__.py:313
None __init__(self, EntityComponent[ImageEntity] component)
Definition: __init__.py:287
web.StreamResponse get(self, web.Request request, str entity_id)
Definition: __init__.py:291
bool remove(self, _T matcher)
Definition: match.py:214
CalendarEntity get_entity(HomeAssistant hass, str entity_id)
Definition: trigger.py:96
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:108
str valid_image_content_type(str|None content_type)
Definition: __init__.py:90
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:144
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:149
None async_handle_snapshot_service(ImageEntity image, ServiceCall service_call)
Definition: __init__.py:410
Image _async_get_image(ImageEntity image_entity, int timeout)
Definition: __init__.py:97
web.StreamResponse async_get_still_stream(web.Request request, ImageEntity image_entity)
Definition: __init__.py:326
None open(self, **Any kwargs)
Definition: lock.py:86
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
httpx.AsyncClient get_async_client(HomeAssistant hass, bool verify_ssl=True)
Definition: httpx_client.py:41