mirror of
https://github.com/home-assistant/core
synced 2024-08-28 03:36:46 +02:00
298 lines
9.8 KiB
Python
298 lines
9.8 KiB
Python
"""The image integration."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import collections
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
from random import SystemRandom
|
|
from typing import TYPE_CHECKING, Final, final
|
|
|
|
from aiohttp import hdrs, web
|
|
import httpx
|
|
|
|
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import Event, HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.config_validation import ( # noqa: F401
|
|
PLATFORM_SCHEMA,
|
|
PLATFORM_SCHEMA_BASE,
|
|
)
|
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
|
from homeassistant.helpers.entity_component import EntityComponent
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
from homeassistant.helpers.httpx_client import get_async_client
|
|
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
|
|
|
from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401
|
|
|
|
if TYPE_CHECKING:
|
|
from functools import cached_property
|
|
else:
|
|
from homeassistant.backports.functools import cached_property
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
|
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
|
|
|
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
|
ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}"
|
|
|
|
TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5)
|
|
_RND: Final = SystemRandom()
|
|
|
|
GET_IMAGE_TIMEOUT: Final = 10
|
|
|
|
|
|
class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
|
|
"""A class that describes image entities."""
|
|
|
|
|
|
@dataclass
|
|
class Image:
|
|
"""Represent an image."""
|
|
|
|
content_type: str
|
|
content: bytes
|
|
|
|
|
|
class ImageContentTypeError(HomeAssistantError):
|
|
"""Error with the content type while loading an image."""
|
|
|
|
|
|
def valid_image_content_type(content_type: str | None) -> str:
|
|
"""Validate the assigned content type is one of an image."""
|
|
if content_type is None or content_type.split("/", 1)[0] != "image":
|
|
raise ImageContentTypeError
|
|
return content_type
|
|
|
|
|
|
async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
|
|
"""Fetch image from an image entity."""
|
|
with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError):
|
|
async with asyncio.timeout(timeout):
|
|
if image_bytes := await image_entity.async_image():
|
|
content_type = valid_image_content_type(image_entity.content_type)
|
|
image = Image(content_type, image_bytes)
|
|
return image
|
|
|
|
raise HomeAssistantError("Unable to get image")
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up the image component."""
|
|
component = hass.data[DOMAIN] = EntityComponent[ImageEntity](
|
|
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
|
)
|
|
|
|
hass.http.register_view(ImageView(component))
|
|
|
|
await component.async_setup(config)
|
|
|
|
@callback
|
|
def update_tokens(time: datetime) -> None:
|
|
"""Update tokens of the entities."""
|
|
for entity in component.entities:
|
|
entity.async_update_token()
|
|
entity.async_write_ha_state()
|
|
|
|
unsub = async_track_time_interval(
|
|
hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Image update tokens"
|
|
)
|
|
|
|
@callback
|
|
def unsub_track_time_interval(_event: Event) -> None:
|
|
"""Unsubscribe track time interval timer."""
|
|
unsub()
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
|
|
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up a config entry."""
|
|
component: EntityComponent[ImageEntity] = hass.data[DOMAIN]
|
|
return await component.async_setup_entry(entry)
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
component: EntityComponent[ImageEntity] = hass.data[DOMAIN]
|
|
return await component.async_unload_entry(entry)
|
|
|
|
|
|
CACHED_PROPERTIES_WITH_ATTR_ = {
|
|
"content_type",
|
|
"image_last_updated",
|
|
"image_url",
|
|
}
|
|
|
|
|
|
class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|
"""The base class for image entities."""
|
|
|
|
_entity_component_unrecorded_attributes = frozenset(
|
|
{"access_token", "entity_picture"}
|
|
)
|
|
|
|
# Entity Properties
|
|
_attr_content_type: str = DEFAULT_CONTENT_TYPE
|
|
_attr_image_last_updated: datetime | None = None
|
|
_attr_image_url: str | None | UndefinedType = UNDEFINED
|
|
_attr_should_poll: bool = False # No need to poll image entities
|
|
_attr_state: None = None # State is determined by last_updated
|
|
_cached_image: Image | None = None
|
|
|
|
def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None:
|
|
"""Initialize an image entity."""
|
|
self._client = get_async_client(hass, verify_ssl=verify_ssl)
|
|
self.access_tokens: collections.deque = collections.deque([], 2)
|
|
self.async_update_token()
|
|
|
|
@cached_property
|
|
def content_type(self) -> str:
|
|
"""Image content type."""
|
|
return self._attr_content_type
|
|
|
|
@property
|
|
def entity_picture(self) -> str | None:
|
|
"""Return a link to the image as entity picture."""
|
|
if self._attr_entity_picture is not None:
|
|
return self._attr_entity_picture
|
|
return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1])
|
|
|
|
@cached_property
|
|
def image_last_updated(self) -> datetime | None:
|
|
"""Time the image was last updated."""
|
|
return self._attr_image_last_updated
|
|
|
|
@cached_property
|
|
def image_url(self) -> str | None | UndefinedType:
|
|
"""Return URL of image."""
|
|
return self._attr_image_url
|
|
|
|
def image(self) -> bytes | None:
|
|
"""Return bytes of image."""
|
|
raise NotImplementedError()
|
|
|
|
async def _fetch_url(self, url: str) -> httpx.Response | None:
|
|
"""Fetch a URL."""
|
|
try:
|
|
response = await self._client.get(
|
|
url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True
|
|
)
|
|
response.raise_for_status()
|
|
return response
|
|
except httpx.TimeoutException:
|
|
_LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url)
|
|
return None
|
|
except (httpx.RequestError, httpx.HTTPStatusError) as err:
|
|
_LOGGER.error(
|
|
"%s: Error getting new image from %s: %s",
|
|
self.entity_id,
|
|
url,
|
|
err,
|
|
)
|
|
return None
|
|
|
|
async def _async_load_image_from_url(self, url: str) -> Image | None:
|
|
"""Load an image by url."""
|
|
if response := await self._fetch_url(url):
|
|
content_type = response.headers.get("content-type")
|
|
try:
|
|
return Image(
|
|
content=response.content,
|
|
content_type=valid_image_content_type(content_type),
|
|
)
|
|
except ImageContentTypeError:
|
|
_LOGGER.error(
|
|
"%s: Image from %s has invalid content type: %s",
|
|
self.entity_id,
|
|
url,
|
|
content_type,
|
|
)
|
|
return None
|
|
return None
|
|
|
|
async def async_image(self) -> bytes | None:
|
|
"""Return bytes of image."""
|
|
|
|
if self._cached_image:
|
|
return self._cached_image.content
|
|
if (url := self.image_url) is not UNDEFINED:
|
|
if not url or (image := await self._async_load_image_from_url(url)) is None:
|
|
return None
|
|
self._cached_image = image
|
|
self._attr_content_type = image.content_type
|
|
return image.content
|
|
return await self.hass.async_add_executor_job(self.image)
|
|
|
|
@property
|
|
@final
|
|
def state(self) -> str | None:
|
|
"""Return the state."""
|
|
if self.image_last_updated is None:
|
|
return None
|
|
return self.image_last_updated.isoformat()
|
|
|
|
@final
|
|
@property
|
|
def state_attributes(self) -> dict[str, str | None]:
|
|
"""Return the state attributes."""
|
|
return {"access_token": self.access_tokens[-1]}
|
|
|
|
@callback
|
|
def async_update_token(self) -> None:
|
|
"""Update the used token."""
|
|
self.access_tokens.append(hex(_RND.getrandbits(256))[2:])
|
|
|
|
|
|
class ImageView(HomeAssistantView):
|
|
"""View to serve an image."""
|
|
|
|
name = "api:image:image"
|
|
requires_auth = False
|
|
url = "/api/image_proxy/{entity_id}"
|
|
|
|
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
|
|
"""Initialize an image view."""
|
|
self.component = component
|
|
|
|
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
|
"""Start a GET request."""
|
|
if (image_entity := self.component.get_entity(entity_id)) is None:
|
|
raise web.HTTPNotFound()
|
|
|
|
authenticated = (
|
|
request[KEY_AUTHENTICATED]
|
|
or request.query.get("token") in image_entity.access_tokens
|
|
)
|
|
|
|
if not authenticated:
|
|
# Attempt with invalid bearer token, raise unauthorized
|
|
# so ban middleware can handle it.
|
|
if hdrs.AUTHORIZATION in request.headers:
|
|
raise web.HTTPUnauthorized()
|
|
# Invalid sigAuth or image entity access token
|
|
raise web.HTTPForbidden()
|
|
|
|
return await self.handle(request, image_entity)
|
|
|
|
async def handle(
|
|
self, request: web.Request, image_entity: ImageEntity
|
|
) -> web.StreamResponse:
|
|
"""Serve image."""
|
|
try:
|
|
image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
|
|
except (HomeAssistantError, ValueError) as ex:
|
|
raise web.HTTPInternalServerError() from ex
|
|
|
|
return web.Response(body=image.content, content_type=image.content_type)
|