diff --git a/.strict-typing b/.strict-typing index 533d5239ca..b9ca324a2e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -165,6 +165,7 @@ homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.input_button.* diff --git a/CODEOWNERS b/CODEOWNERS index 0e918caade..4d8b309dfe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -542,6 +542,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 diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py new file mode 100644 index 0000000000..321757358f --- /dev/null +++ b/homeassistant/components/image/__init__.py @@ -0,0 +1,209 @@ +"""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 +import attr + +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.""" + + +@attr.s +class Image: + """Represent an image.""" + + content_type: str = attr.ib() + content: bytes = attr.ib() + + +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, "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_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.content_type: str = DEFAULT_CONTENT_TYPE + self.access_tokens: collections.deque = collections.deque([], 2) + self.async_update_token() + + @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 last_updated(self) -> datetime | None: + """The time when the image was last updated.""" + if self._attr_last_updated is not None: + return self._attr_last_updated + return None + + 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.last_updated is None: + return None + return self.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) diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py new file mode 100644 index 0000000000..d262bb460f --- /dev/null +++ b/homeassistant/components/image/const.py @@ -0,0 +1,6 @@ +"""Constants for the image integration.""" +from typing import Final + +DOMAIN: Final = "image" + +IMAGE_TIMEOUT: Final = 10 diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json new file mode 100644 index 0000000000..0335710a30 --- /dev/null +++ b/homeassistant/components/image/manifest.json @@ -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" +} diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py new file mode 100644 index 0000000000..5c14122088 --- /dev/null +++ b/homeassistant/components/image/recorder.py @@ -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"} diff --git a/homeassistant/components/image/strings.json b/homeassistant/components/image/strings.json new file mode 100644 index 0000000000..ea7ecd1695 --- /dev/null +++ b/homeassistant/components/image/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Image", + "entity_component": { + "_": { + "name": "[%key:component::image::title%]" + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index 23b4a9a132..6c3e301c9a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -35,6 +35,7 @@ class Platform(StrEnum): FAN = "fan" GEO_LOCATION = "geo_location" HUMIDIFIER = "humidifier" + IMAGE = "image" IMAGE_PROCESSING = "image_processing" LIGHT = "light" LOCK = "lock" diff --git a/mypy.ini b/mypy.ini index b3a4cafba3..8bdf5d67fd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1412,6 +1412,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 diff --git a/tests/components/image/__init__.py b/tests/components/image/__init__.py new file mode 100644 index 0000000000..eacf56cc20 --- /dev/null +++ b/tests/components/image/__init__.py @@ -0,0 +1 @@ +"""The tests for the image integration.""" diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py new file mode 100644 index 0000000000..1815d28411 --- /dev/null +++ b/tests/components/image/conftest.py @@ -0,0 +1,57 @@ +"""Test helpers for image.""" + +import pytest + +from homeassistant.components import image +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_setup_component +from homeassistant.util import datetime as dt_util + +from tests.common import MockModule, mock_integration, mock_platform + + +class MockImageEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImage: + """A mock image platform.""" + + PLATFORM_SCHEMA = image.PLATFORM_SCHEMA + + async def async_setup_platform( + self, + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up the mock image platform.""" + async_add_entities( + [ + MockImageEntity(), + ] + ) + + +@pytest.fixture(name="mock_image") +async def mock_image_fixture(hass: HomeAssistant): + """Initialize a mock image platform.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImage()) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py new file mode 100644 index 0000000000..34058a441e --- /dev/null +++ b/tests/components/image/test_init.py @@ -0,0 +1,60 @@ +"""The tests for the image component.""" +from http import HTTPStatus + +import pytest + +from homeassistant.core import HomeAssistant + +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_state( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image +) -> None: + """Test image state.""" + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + + +async def test_fetch_image_authenticated( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + resp = await client.get("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + +async def test_fetch_image_unauthenticated( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, mock_image +) -> None: + """Test fetching an image with an unauthenticated client.""" + client = await hass_client_no_auth() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.FORBIDDEN + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.FORBIDDEN + + state = hass.states.get("image.test") + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + resp = await client.get("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND