mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
Add image entity component (#90564)
This commit is contained in:
parent
43c4dec3ed
commit
5303bef83e
@ -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/**
|
||||
|
@ -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.*
|
||||
|
@ -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
|
||||
|
211
homeassistant/components/image/__init__.py
Normal file
211
homeassistant/components/image/__init__.py
Normal file
@ -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
homeassistant/components/image/const.py
Normal file
6
homeassistant/components/image/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Constants for the image integration."""
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "image"
|
||||
|
||||
IMAGE_TIMEOUT: Final = 10
|
9
homeassistant/components/image/manifest.json
Normal file
9
homeassistant/components/image/manifest.json
Normal file
@ -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
homeassistant/components/image/recorder.py
Normal file
10
homeassistant/components/image/recorder.py
Normal file
@ -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
homeassistant/components/image/strings.json
Normal file
8
homeassistant/components/image/strings.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Image",
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::image::title%]"
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
|
66
homeassistant/components/kitchen_sink/image.py
Normal file
66
homeassistant/components/kitchen_sink/image.py
Normal file
@ -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)
|
BIN
homeassistant/components/kitchen_sink/qr_code.png
Normal file
BIN
homeassistant/components/kitchen_sink/qr_code.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
@ -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"
|
||||
|
10
mypy.ini
10
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
|
||||
|
1
tests/components/image/__init__.py
Normal file
1
tests/components/image/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""The tests for the image integration."""
|
160
tests/components/image/conftest.py
Normal file
160
tests/components/image/conftest.py
Normal file
@ -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()
|
169
tests/components/image/test_init.py
Normal file
169
tests/components/image/test_init.py
Normal file
@ -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
|
40
tests/components/image/test_recorder.py
Normal file
40
tests/components/image/test_recorder.py
Normal file
@ -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
|
60
tests/components/kitchen_sink/test_image.py
Normal file
60
tests/components/kitchen_sink/test_image.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user