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 9af81c59934..b1870654be0 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 801827df6dc..39480601388 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 dfa2d0d045a..cf747b9b69c 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 00000000000..bff9e8cc4c6 --- /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 00000000000..d262bb460f7 --- /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 00000000000..0335710a30b --- /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 00000000000..5c141220881 --- /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 00000000000..ea7ecd16956 --- /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 39143c8b84b..7857e6b3149 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,7 +26,7 @@ import homeassistant.util.dt as dt_util 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 00000000000..7719b188c38 --- /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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∓qLziA1dQm31A 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 IbxsLQ09kBBhX4Qo literal 0 HcmV?d00001 diff --git a/homeassistant/const.py b/homeassistant/const.py index 94c932b1fd1..262457de436 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 8628353ef6a..df689b5fc9d 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 00000000000..eacf56cc206 --- /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 00000000000..3dad2932928 --- /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 00000000000..5be9eefa0cc --- /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 00000000000..f0ecc43e6dc --- /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 00000000000..4c64bd77eb2 --- /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