Skip to content

Commit

Permalink
Add image entity component (home-assistant#90564)
Browse files Browse the repository at this point in the history
  • Loading branch information
emontnemery authored Jun 19, 2023
1 parent 43c4dec commit 5303bef
Show file tree
Hide file tree
Showing 18 changed files with 756 additions and 1 deletion.
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ base_platforms: &base_platforms
- homeassistant/components/fan/**
- homeassistant/components/geo_location/**
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ homeassistant.components.huawei_lte.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.ibeacon.*
homeassistant.components.image.*
homeassistant.components.image_processing.*
homeassistant.components.image_upload.*
homeassistant.components.imap.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ build.json @home-assistant/supervisor
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/image/ @home-assistant/core
/tests/components/image/ @home-assistant/core
/homeassistant/components/image_processing/ @home-assistant/core
/tests/components/image_processing/ @home-assistant/core
/homeassistant/components/image_upload/ @home-assistant/core
Expand Down
211 changes: 211 additions & 0 deletions homeassistant/components/image/__init__.py
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)
6 changes: 6 additions & 0 deletions homeassistant/components/image/const.py
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
9 changes: 9 additions & 0 deletions homeassistant/components/image/manifest.json
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"
}
10 changes: 10 additions & 0 deletions homeassistant/components/image/recorder.py
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"}
8 changes: 8 additions & 0 deletions homeassistant/components/image/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"title": "Image",
"entity_component": {
"_": {
"name": "[%key:component::image::title%]"
}
}
}
2 changes: 1 addition & 1 deletion homeassistant/components/kitchen_sink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
DOMAIN = "kitchen_sink"


COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK]
COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK, Platform.IMAGE]

CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

Expand Down
66 changes: 66 additions & 0 deletions homeassistant/components/kitchen_sink/image.py
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)
Binary file added homeassistant/components/kitchen_sink/qr_code.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Platform(StrEnum):
FAN = "fan"
GEO_LOCATION = "geo_location"
HUMIDIFIER = "humidifier"
IMAGE = "image"
IMAGE_PROCESSING = "image_processing"
LIGHT = "light"
LOCK = "lock"
Expand Down
10 changes: 10 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.image.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.image_processing.*]
check_untyped_defs = true
disallow_incomplete_defs = true
Expand Down
1 change: 1 addition & 0 deletions tests/components/image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The tests for the image integration."""
Loading

0 comments on commit 5303bef

Please sign in to comment.