forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add image entity component (home-assistant#90564)
- Loading branch information
1 parent
43c4dec
commit 5303bef
Showing
18 changed files
with
756 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
"""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 Final, final | ||
|
||
from aiohttp import hdrs, web | ||
import async_timeout | ||
|
||
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.typing import ConfigType | ||
|
||
from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 | ||
|
||
_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() | ||
|
||
|
||
@dataclass | ||
class ImageEntityDescription(EntityDescription): | ||
"""A class that describes image entities.""" | ||
|
||
|
||
@dataclass | ||
class Image: | ||
"""Represent an image.""" | ||
|
||
content_type: str | ||
content: bytes | ||
|
||
|
||
async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: | ||
"""Fetch image from an image entity.""" | ||
with suppress(asyncio.CancelledError, asyncio.TimeoutError): | ||
async with async_timeout.timeout(timeout): | ||
if image_bytes := await image_entity.async_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) | ||
|
||
|
||
class ImageEntity(Entity): | ||
"""The base class for image entities.""" | ||
|
||
# Entity Properties | ||
_attr_content_type: str = DEFAULT_CONTENT_TYPE | ||
_attr_image_last_updated: datetime | None = None | ||
_attr_should_poll: bool = False # No need to poll image entities | ||
_attr_state: None = None # State is determined by last_updated | ||
|
||
def __init__(self) -> None: | ||
"""Initialize an image entity.""" | ||
self.access_tokens: collections.deque = collections.deque([], 2) | ||
self.async_update_token() | ||
|
||
@property | ||
def content_type(self) -> str: | ||
"""Image content type.""" | ||
return self._attr_content_type | ||
|
||
@property | ||
def entity_picture(self) -> str: | ||
"""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]) | ||
|
||
@property | ||
def image_last_updated(self) -> datetime | None: | ||
"""The time when the image was last updated.""" | ||
return self._attr_image_last_updated | ||
|
||
def image(self) -> bytes | None: | ||
"""Return bytes of image.""" | ||
raise NotImplementedError() | ||
|
||
async def async_image(self) -> bytes | None: | ||
"""Return bytes of image.""" | ||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
"""Constants for the image integration.""" | ||
from typing import Final | ||
|
||
DOMAIN: Final = "image" | ||
|
||
IMAGE_TIMEOUT: Final = 10 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
{ | ||
"domain": "image", | ||
"name": "Image", | ||
"codeowners": ["@home-assistant/core"], | ||
"dependencies": ["http"], | ||
"documentation": "https://www.home-assistant.io/integrations/image", | ||
"integration_type": "entity", | ||
"quality_scale": "internal" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
"""Integration platform for recorder.""" | ||
from __future__ import annotations | ||
|
||
from homeassistant.core import HomeAssistant, callback | ||
|
||
|
||
@callback | ||
def exclude_attributes(hass: HomeAssistant) -> set[str]: | ||
"""Exclude access_token and entity_picture from being recorded in the database.""" | ||
return {"access_token", "entity_picture"} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"title": "Image", | ||
"entity_component": { | ||
"_": { | ||
"name": "[%key:component::image::title%]" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
"""Demo image platform.""" | ||
from __future__ import annotations | ||
|
||
from pathlib import Path | ||
|
||
from homeassistant.components.image import ImageEntity | ||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.helpers.entity_platform import AddEntitiesCallback | ||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType | ||
from homeassistant.util import dt as dt_util | ||
|
||
|
||
async def async_setup_platform( | ||
hass: HomeAssistant, | ||
config: ConfigType, | ||
async_add_entities: AddEntitiesCallback, | ||
discovery_info: DiscoveryInfoType | None = None, | ||
) -> None: | ||
"""Set up image entities.""" | ||
async_add_entities( | ||
[ | ||
DemoImage( | ||
"kitchen_sink_image_001", | ||
"QR Code", | ||
"image/png", | ||
"qr_code.png", | ||
), | ||
] | ||
) | ||
|
||
|
||
async def async_setup_entry( | ||
hass: HomeAssistant, | ||
config_entry: ConfigEntry, | ||
async_add_entities: AddEntitiesCallback, | ||
) -> None: | ||
"""Set up the Everything but the Kitchen Sink config entry.""" | ||
await async_setup_platform(hass, {}, async_add_entities) | ||
|
||
|
||
class DemoImage(ImageEntity): | ||
"""Representation of an image entity.""" | ||
|
||
def __init__( | ||
self, | ||
unique_id: str, | ||
name: str, | ||
content_type: str, | ||
image: str, | ||
) -> None: | ||
"""Initialize the image entity.""" | ||
super().__init__() | ||
self._attr_content_type = content_type | ||
self._attr_name = name | ||
self._attr_unique_id = unique_id | ||
self._image_filename = image | ||
|
||
async def async_added_to_hass(self): | ||
"""Set the update time.""" | ||
self._attr_image_last_updated = dt_util.utcnow() | ||
|
||
async def async_image(self) -> bytes | None: | ||
"""Return bytes of image.""" | ||
image_path = Path(__file__).parent / self._image_filename | ||
return await self.hass.async_add_executor_job(image_path.read_bytes) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""The tests for the image integration.""" |
Oops, something went wrong.