1 """The image integration."""
3 from __future__
import annotations
7 from contextlib
import suppress
8 from dataclasses
import dataclass
9 from datetime
import datetime, timedelta
12 from random
import SystemRandom
13 from typing
import Final, final
15 from aiohttp
import hdrs, web
17 from propcache
import cached_property
18 import voluptuous
as vol
25 EventStateChangedData,
35 async_track_state_change_event,
36 async_track_time_interval,
46 from .const
import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT
48 _LOGGER = logging.getLogger(__name__)
50 SERVICE_SNAPSHOT: Final =
"snapshot"
52 ENTITY_ID_FORMAT: Final = DOMAIN +
".{}"
53 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
54 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
57 ATTR_FILENAME: Final =
"filename"
59 DEFAULT_CONTENT_TYPE: Final =
"image/jpeg"
60 ENTITY_IMAGE_URL: Final =
"/api/image_proxy/{0}?token={1}"
63 _RND: Final = SystemRandom()
65 GET_IMAGE_TIMEOUT: Final = 10
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")
71 IMAGE_SERVICE_SNAPSHOT: VolDictType = {vol.Required(ATTR_FILENAME): cv.string}
75 """A class that describes image entities."""
80 """Represent an image."""
87 """Error with the content type while loading an image."""
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
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():
103 return Image(content_type, image_bytes)
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
114 hass.http.register_view(
ImageView(component))
117 await component.async_setup(config)
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()
127 hass, update_tokens, TOKEN_CHANGE_INTERVAL, name=
"Image update tokens"
131 def unsub_track_time_interval(_event: Event) ->
None:
132 """Unsubscribe track time interval timer."""
135 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
137 component.async_register_entity_service(
138 SERVICE_SNAPSHOT, IMAGE_SERVICE_SNAPSHOT, async_handle_snapshot_service
145 """Set up a config entry."""
150 """Unload a config entry."""
154 CACHED_PROPERTIES_WITH_ATTR_ = {
156 "image_last_updated",
162 """The base class for image entities."""
164 _entity_component_unrecorded_attributes = frozenset(
165 {
"access_token",
"entity_picture"}
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
173 _attr_state:
None =
None
174 _cached_image: Image |
None =
None
176 def __init__(self, hass: HomeAssistant, verify_ssl: bool =
False) ->
None:
177 """Initialize an image entity."""
179 self.access_tokens: collections.deque = collections.deque([], 2)
184 """Image content type."""
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])
196 """Time the image was last updated."""
197 return self._attr_image_last_updated
201 """Return URL of image."""
202 return self._attr_image_url
205 """Return bytes of image."""
206 raise NotImplementedError
208 async
def _fetch_url(self, url: str) -> httpx.Response |
None:
212 url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=
True
214 response.raise_for_status()
215 except httpx.TimeoutException:
216 _LOGGER.error(
"%s: Timeout getting image from %s", self.
entity_identity_id, url)
218 except (httpx.RequestError, httpx.HTTPStatusError)
as err:
220 "%s: Error getting new image from %s: %s",
229 """Load an image by url."""
230 if response := await self.
_fetch_url_fetch_url(url):
231 content_type = response.headers.get(
"content-type")
234 content=response.content,
237 except ImageContentTypeError:
239 "%s: Image from %s has invalid content type: %s",
248 """Return bytes of image."""
252 if (url := self.
image_urlimage_url)
is not UNDEFINED:
258 return await self.
hasshass.async_add_executor_job(self.
imageimage)
263 """Return the state."""
271 """Return the state attributes."""
272 return {
"access_token": self.access_tokens[-1]}
276 """Update the used token."""
277 self.access_tokens.append(hex(_RND.getrandbits(256))[2:])
281 """View to serve an image."""
283 name =
"api:image:image"
284 requires_auth =
False
285 url =
"/api/image_proxy/{entity_id}"
287 def __init__(self, component: EntityComponent[ImageEntity]) ->
None:
288 """Initialize an image view."""
291 async
def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
292 """Start a GET request."""
294 raise web.HTTPNotFound
297 request[KEY_AUTHENTICATED]
298 or request.query.get(
"token")
in image_entity.access_tokens
301 if not authenticated:
304 if hdrs.AUTHORIZATION
in request.headers:
305 raise web.HTTPUnauthorized
307 raise web.HTTPForbidden
309 return await self.
handlehandle(request, image_entity)
312 self, request: web.Request, image_entity: ImageEntity
313 ) -> web.StreamResponse:
317 except (HomeAssistantError, ValueError)
as ex:
318 raise web.HTTPInternalServerError
from ex
320 return web.Response(body=image.content, content_type=image.content_type)
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)
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)
337 frame = bytearray(FRAME_SEPARATOR)
339 f
"Content-Type: {image_entity.content_type}\r\n"
340 f
"Content-Length: {len(img_bytes)}\r\n\r\n",
344 frame.extend(img_bytes)
351 await response.write(frame)
354 event = asyncio.Event()
358 def _async_image_state_update(_event: Event[EventStateChangedData]) ->
None:
359 """Write image to stream."""
363 def _async_timeout_reached() -> None:
364 """Handle timeout."""
369 hass = request.app[KEY_HASS]
373 image_entity.entity_id,
374 _async_image_state_update,
376 timeout_handle =
None
379 if not await _write_frame():
383 timeout_handle = loop.call_later(55, _async_timeout_reached)
387 timeout_handle.cancel()
391 timeout_handle.cancel()
396 """Image View to serve an multipart stream."""
398 url =
"/api/image_proxy_stream/{entity_id}"
399 name =
"api:image:stream"
402 self, request: web.Request, image_entity: ImageEntity
403 ) -> web.StreamResponse:
404 """Serve image stream."""
409 image: ImageEntity, service_call: ServiceCall
411 """Handle snapshot services calls."""
413 snapshot_file: str = service_call.data[ATTR_FILENAME]
416 if not hass.config.is_allowed_path(snapshot_file):
418 f
"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
421 async
with asyncio.timeout(IMAGE_TIMEOUT):
422 image_data = await image.async_image()
424 if image_data
is None:
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)
434 await hass.async_add_executor_job(_write_image, snapshot_file, image_data)
435 except OSError
as err:
Image|None _async_load_image_from_url(self, str url)
datetime|None image_last_updated(self)
dict[str, str|None] state_attributes(self)
None __init__(self, HomeAssistant hass, bool verify_ssl=False)
httpx.Response|None _fetch_url(self, str url)
None async_update_token(self)
str|None|UndefinedType image_url(self)
str|None entity_picture(self)
bytes|None async_image(self)
web.StreamResponse handle(self, web.Request request, ImageEntity image_entity)
web.StreamResponse handle(self, web.Request request, ImageEntity image_entity)
None __init__(self, EntityComponent[ImageEntity] component)
web.StreamResponse get(self, web.Request request, str entity_id)
bool remove(self, _T matcher)
CalendarEntity get_entity(HomeAssistant hass, str entity_id)
web.Response get(self, web.Request request, str config_key)
bool async_setup(HomeAssistant hass, ConfigType config)
str valid_image_content_type(str|None content_type)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
None async_handle_snapshot_service(ImageEntity image, ServiceCall service_call)
Image _async_get_image(ImageEntity image_entity, int timeout)
web.StreamResponse async_get_still_stream(web.Request request, ImageEntity image_entity)
None open(self, **Any kwargs)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
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)
httpx.AsyncClient get_async_client(HomeAssistant hass, bool verify_ssl=True)