From 5303bef83eaa23c65299b2c24d9390f85fb48365 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 19 Jun 2023 17:03:48 +0200 Subject: [PATCH] Add image entity component (#90564) --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/image/__init__.py | 211 ++++++++++++++++++ homeassistant/components/image/const.py | 6 + homeassistant/components/image/manifest.json | 9 + homeassistant/components/image/recorder.py | 10 + homeassistant/components/image/strings.json | 8 + .../components/kitchen_sink/__init__.py | 2 +- .../components/kitchen_sink/image.py | 66 ++++++ .../components/kitchen_sink/qr_code.png | Bin 0 -> 14425 bytes homeassistant/const.py | 1 + mypy.ini | 10 + tests/components/image/__init__.py | 1 + tests/components/image/conftest.py | 160 +++++++++++++ tests/components/image/test_init.py | 169 ++++++++++++++ tests/components/image/test_recorder.py | 40 ++++ tests/components/kitchen_sink/test_image.py | 60 +++++ 18 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/image/__init__.py create mode 100644 homeassistant/components/image/const.py create mode 100644 homeassistant/components/image/manifest.json create mode 100644 homeassistant/components/image/recorder.py create mode 100644 homeassistant/components/image/strings.json create mode 100644 homeassistant/components/kitchen_sink/image.py create mode 100644 homeassistant/components/kitchen_sink/qr_code.png create mode 100644 tests/components/image/__init__.py create mode 100644 tests/components/image/conftest.py create mode 100644 tests/components/image/test_init.py create mode 100644 tests/components/image/test_recorder.py create mode 100644 tests/components/kitchen_sink/test_image.py diff --git a/.core_files.yaml b/.core_files.yaml index 9af81c599348ad..b1870654be06f2 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -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/** diff --git a/.strict-typing b/.strict-typing index 801827df6dcc5d..3948060138807a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index dfa2d0d045a22e..cf747b9b69cc96 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py new file mode 100644 index 00000000000000..bff9e8cc4c67b6 --- /dev/null +++ b/homeassistant/components/image/__init__.py @@ -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) diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py new file mode 100644 index 00000000000000..d262bb460f701e --- /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 00000000000000..0335710a30bb48 --- /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 00000000000000..5c14122088100c --- /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 00000000000000..ea7ecd1695627b --- /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/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 39143c8b84b7b3..7857e6b31495d4 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -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) diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py new file mode 100644 index 00000000000000..7719b188c38ed2 --- /dev/null +++ b/homeassistant/components/kitchen_sink/image.py @@ -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) diff --git a/homeassistant/components/kitchen_sink/qr_code.png b/homeassistant/components/kitchen_sink/qr_code.png new file mode 100644 index 0000000000000000000000000000000000000000..d8350728b633a9c70fa87edce1fb19a7d0ac17ff GIT binary patch literal 14425 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 literal 0 HcmV?d00001 diff --git a/homeassistant/const.py b/homeassistant/const.py index 94c932b1fd1001..262457de436933 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -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" diff --git a/mypy.ini b/mypy.ini index 8628353ef6a709..df689b5fc9d97d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/tests/components/image/__init__.py b/tests/components/image/__init__.py new file mode 100644 index 00000000000000..eacf56cc206524 --- /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 00000000000000..3dad29329286f2 --- /dev/null +++ b/tests/components/image/conftest.py @@ -0,0 +1,160 @@ +"""Test helpers for image.""" +from collections.abc import Generator + +import pytest + +from homeassistant.components import image +from homeassistant.config_entries import ConfigEntry, ConfigFlow +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 dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockImageEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + 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.""" + return b"Test" + + +class MockImageNoStateEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImageSyncEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + def image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + +class MockImageConfigEntry: + """A mock image config entry.""" + + def __init__(self, entities: list[image.ImageEntity]) -> None: + """Initialize.""" + self._entities = entities + + async def async_setup_entry( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test image platform via config entry.""" + async_add_entities([self._entities]) + + +class MockImagePlatform: + """A mock image platform.""" + + PLATFORM_SCHEMA = image.PLATFORM_SCHEMA + + def __init__(self, entities: list[image.ImageEntity]) -> None: + """Initialize.""" + self._entities = entities + + 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(self._entities) + + +@pytest.fixture(name="config_flow") +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + + class MockFlow(ConfigFlow): + """Test flow.""" + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(name="mock_image_config_entry") +async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow): + """Initialize a mock image config_entry.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, image.DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, image.DOMAIN) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + mock_platform( + hass, f"{TEST_DOMAIN}.{image.DOMAIN}", MockImageConfigEntry(MockImageEntity()) + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="mock_image_platform") +async def mock_image_platform_fixture(hass: HomeAssistant): + """Initialize a mock image platform.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageEntity()])) + 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 00000000000000..5be9eefa0cce3b --- /dev/null +++ b/tests/components/image/test_init.py @@ -0,0 +1,169 @@ +"""The tests for the image component.""" +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import hdrs +import pytest + +from homeassistant.components import image +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import ( + MockImageEntity, + MockImageNoStateEntity, + MockImagePlatform, + MockImageSyncEntity, +) + +from tests.common import MockModule, mock_integration, mock_platform +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_platform +) -> None: + """Test image state.""" + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+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", + } + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_config_entry( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_config_entry +) -> None: + """Test setting up an image platform from a config entry.""" + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+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", + } + + +@pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") +async def test_state_attr( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test image state with entity picture from attr.""" + mock_integration(hass, MockModule(domain="test")) + entity = MockImageEntity() + entity._attr_entity_picture = "abcd" + mock_platform(hass, "test.image", MockImagePlatform([entity])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "2023-04-01T00:00:00+00:00" + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": "abcd", + "friendly_name": "Test", + } + + +async def test_no_state( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test image state.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageNoStateEntity()])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("image.test") + assert state.state == "unknown" + 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_platform +) -> 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_fail( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_platform +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + with patch.object(MockImageEntity, "async_image", side_effect=TimeoutError): + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_fetch_image_sync( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test fetching an image with an authenticated client.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity()])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + 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" + + +async def test_fetch_image_unauthenticated( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_image_platform, +) -> 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 + + resp = await client.get( + "/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"} + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + 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 diff --git a/tests/components/image/test_recorder.py b/tests/components/image/test_recorder.py new file mode 100644 index 00000000000000..f0ecc43e6dcc85 --- /dev/null +++ b/tests/components/image/test_recorder.py @@ -0,0 +1,40 @@ +"""The tests for image recorder.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes( + recorder_mock: Recorder, hass: HomeAssistant, mock_image_platform +) -> None: + """Test camera registered attributes to be excluded.""" + now = dt_util.utcnow() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) + assert len(states) == 1 + for entity_states in states.values(): + for state in entity_states: + assert "access_token" not in state.attributes + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ATTRIBUTION not in state.attributes + assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/kitchen_sink/test_image.py b/tests/components/kitchen_sink/test_image.py new file mode 100644 index 00000000000000..4c64bd77eb2773 --- /dev/null +++ b/tests/components/kitchen_sink/test_image.py @@ -0,0 +1,60 @@ +"""The tests for the kitchen_sink image platform.""" +from http import HTTPStatus +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.components.kitchen_sink import DOMAIN, image +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +async def image_only() -> None: + """Enable only the image platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.IMAGE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, image_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant) -> None: + """Test the expected image entities are added.""" + states = hass.states.async_all() + assert len(states) == 1 + state = states[0] + + access_token = state.attributes["access_token"] + assert state.entity_id == "image.qr_code" + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.qr_code?token={access_token}", + "friendly_name": "QR Code", + } + + +async def test_fetch_image( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test fetching an image with an authenticated client.""" + client = await hass_client() + + image_path = Path(image.__file__).parent / "qr_code.png" + expected_data = await hass.async_add_executor_job(image_path.read_bytes) + + resp = await client.get("/api/image_proxy/image.qr_code") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == expected_data