Add snapshot service to image entity (#110057)
* Add service definition for saving snapshot of image entity * Add service to image * Add tests for image entity service * Fix tests * Formatting * Add service icon * Formatting * Formatting * Raise home assistant error instead of single log error * Correctly pass entity id * Raise exception from existing exception * Expect home assistant error * Fix services example * Add test for templated snapshot * Correct icon service config * Set correct type for service template * Remove unneeded Co-authored-by: Erik Montnemery <erik@montnemery.com> * remove template * fix imports * Update homeassistant/components/image/__init__.py * Apply suggestions from code review --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
4a94fb91d7
commit
d40341f1ad
6 changed files with 209 additions and 4 deletions
|
@ -8,19 +8,27 @@ from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
from typing import Final, final
|
from typing import Final, final
|
||||||
|
|
||||||
from aiohttp import hdrs, web
|
from aiohttp import hdrs, web
|
||||||
import httpx
|
import httpx
|
||||||
from propcache import cached_property
|
from propcache import cached_property
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
from homeassistant.core import (
|
||||||
|
Event,
|
||||||
|
EventStateChangedData,
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
|
@ -28,17 +36,26 @@ from homeassistant.helpers.event import (
|
||||||
async_track_time_interval,
|
async_track_time_interval,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
from homeassistant.helpers.typing import (
|
||||||
|
UNDEFINED,
|
||||||
|
ConfigType,
|
||||||
|
UndefinedType,
|
||||||
|
VolDictType,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT
|
from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVICE_SNAPSHOT: Final = "snapshot"
|
||||||
|
|
||||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||||
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
||||||
|
|
||||||
|
ATTR_FILENAME: Final = "filename"
|
||||||
|
|
||||||
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
|
||||||
ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}"
|
ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}"
|
||||||
|
|
||||||
|
@ -51,6 +68,8 @@ FRAME_BOUNDARY = "frame-boundary"
|
||||||
FRAME_SEPARATOR = bytes(f"\r\n--{FRAME_BOUNDARY}\r\n", "utf-8")
|
FRAME_SEPARATOR = bytes(f"\r\n--{FRAME_BOUNDARY}\r\n", "utf-8")
|
||||||
LAST_FRAME_MARKER = bytes(f"\r\n--{FRAME_BOUNDARY}--\r\n", "utf-8")
|
LAST_FRAME_MARKER = bytes(f"\r\n--{FRAME_BOUNDARY}--\r\n", "utf-8")
|
||||||
|
|
||||||
|
IMAGE_SERVICE_SNAPSHOT: VolDictType = {vol.Required(ATTR_FILENAME): cv.string}
|
||||||
|
|
||||||
|
|
||||||
class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
|
class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||||
"""A class that describes image entities."""
|
"""A class that describes image entities."""
|
||||||
|
@ -115,6 +134,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
|
||||||
|
|
||||||
|
component.async_register_entity_service(
|
||||||
|
SERVICE_SNAPSHOT, IMAGE_SERVICE_SNAPSHOT, async_handle_snapshot_service
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -380,3 +403,34 @@ class ImageStreamView(ImageView):
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
"""Serve image stream."""
|
"""Serve image stream."""
|
||||||
return await async_get_still_stream(request, image_entity)
|
return await async_get_still_stream(request, image_entity)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_handle_snapshot_service(
|
||||||
|
image: ImageEntity, service_call: ServiceCall
|
||||||
|
) -> None:
|
||||||
|
"""Handle snapshot services calls."""
|
||||||
|
hass = image.hass
|
||||||
|
snapshot_file: str = service_call.data[ATTR_FILENAME]
|
||||||
|
|
||||||
|
# check if we allow to access to that file
|
||||||
|
if not hass.config.is_allowed_path(snapshot_file):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with asyncio.timeout(IMAGE_TIMEOUT):
|
||||||
|
image_data = await image.async_image()
|
||||||
|
|
||||||
|
if image_data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _write_image(to_file: str, image_data: bytes) -> None:
|
||||||
|
"""Executor helper to write image."""
|
||||||
|
os.makedirs(os.path.dirname(to_file), exist_ok=True)
|
||||||
|
with open(to_file, "wb") as img_file:
|
||||||
|
img_file.write(image_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await hass.async_add_executor_job(_write_image, snapshot_file, image_data)
|
||||||
|
except OSError as err:
|
||||||
|
raise HomeAssistantError("Can't write image to file") from err
|
||||||
|
|
|
@ -3,5 +3,10 @@
|
||||||
"_": {
|
"_": {
|
||||||
"default": "mdi:image"
|
"default": "mdi:image"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"snapshot": {
|
||||||
|
"service": "mdi:camera"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
12
homeassistant/components/image/services.yaml
Normal file
12
homeassistant/components/image/services.yaml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Describes the format for available image services
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
domain: image
|
||||||
|
fields:
|
||||||
|
filename:
|
||||||
|
required: true
|
||||||
|
example: "/tmp/image_snapshot.jpg"
|
||||||
|
selector:
|
||||||
|
text:
|
|
@ -4,5 +4,17 @@
|
||||||
"_": {
|
"_": {
|
||||||
"name": "[%key:component::image::title%]"
|
"name": "[%key:component::image::title%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"snapshot": {
|
||||||
|
"name": "Take snapshot",
|
||||||
|
"description": "Takes a snapshot from an image.",
|
||||||
|
"fields": {
|
||||||
|
"filename": {
|
||||||
|
"name": "Filename",
|
||||||
|
"description": "Template of a filename. Variable available is `entity_id`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,6 +88,16 @@ class MockImageNoStateEntity(image.ImageEntity):
|
||||||
return b"Test"
|
return b"Test"
|
||||||
|
|
||||||
|
|
||||||
|
class MockImageNoDataEntity(image.ImageEntity):
|
||||||
|
"""Mock image entity."""
|
||||||
|
|
||||||
|
_attr_name = "Test"
|
||||||
|
|
||||||
|
async def async_image(self) -> bytes | None:
|
||||||
|
"""Return bytes of image."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class MockImageSyncEntity(image.ImageEntity):
|
class MockImageSyncEntity(image.ImageEntity):
|
||||||
"""Mock image entity."""
|
"""Mock image entity."""
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import ssl
|
import ssl
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
|
||||||
from aiohttp import hdrs
|
from aiohttp import hdrs
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
@ -13,13 +13,16 @@ import respx
|
||||||
|
|
||||||
from homeassistant.components import image
|
from homeassistant.components import image
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
MockImageEntity,
|
MockImageEntity,
|
||||||
MockImageEntityCapitalContentType,
|
MockImageEntityCapitalContentType,
|
||||||
MockImageEntityInvalidContentType,
|
MockImageEntityInvalidContentType,
|
||||||
|
MockImageNoDataEntity,
|
||||||
MockImageNoStateEntity,
|
MockImageNoStateEntity,
|
||||||
MockImagePlatform,
|
MockImagePlatform,
|
||||||
MockImageSyncEntity,
|
MockImageSyncEntity,
|
||||||
|
@ -381,3 +384,112 @@ async def test_image_stream(
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
await close_future
|
await close_future
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_service(hass: HomeAssistant) -> None:
|
||||||
|
"""Test snapshot service."""
|
||||||
|
mopen = mock_open()
|
||||||
|
mock_integration(hass, MockModule(domain="test"))
|
||||||
|
mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity(hass)]))
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, image.DOMAIN, {"image": {"platform": "test"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("homeassistant.components.image.open", mopen, create=True),
|
||||||
|
patch("homeassistant.components.image.os.makedirs"),
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
image.DOMAIN,
|
||||||
|
image.SERVICE_SNAPSHOT,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "image.test",
|
||||||
|
image.ATTR_FILENAME: "/test/snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_write = mopen().write
|
||||||
|
|
||||||
|
assert len(mock_write.mock_calls) == 1
|
||||||
|
assert mock_write.mock_calls[0][1][0] == b"Test"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_service_no_image(hass: HomeAssistant) -> None:
|
||||||
|
"""Test snapshot service with no image."""
|
||||||
|
mopen = mock_open()
|
||||||
|
mock_integration(hass, MockModule(domain="test"))
|
||||||
|
mock_platform(hass, "test.image", MockImagePlatform([MockImageNoDataEntity(hass)]))
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, image.DOMAIN, {"image": {"platform": "test"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("homeassistant.components.image.open", mopen, create=True),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.image.os.makedirs",
|
||||||
|
),
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
image.DOMAIN,
|
||||||
|
image.SERVICE_SNAPSHOT,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "image.test",
|
||||||
|
image.ATTR_FILENAME: "/test/snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_write = mopen().write
|
||||||
|
|
||||||
|
assert len(mock_write.mock_calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None:
|
||||||
|
"""Test snapshot service with a not allowed path."""
|
||||||
|
mock_integration(hass, MockModule(domain="test"))
|
||||||
|
mock_platform(hass, "test.image", MockImagePlatform([MockURLImageEntity(hass)]))
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, image.DOMAIN, {"image": {"platform": "test"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="/test/snapshot.jpg"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
image.DOMAIN,
|
||||||
|
image.SERVICE_SNAPSHOT,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "image.test",
|
||||||
|
image.ATTR_FILENAME: "/test/snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_service_os_error(hass: HomeAssistant) -> None:
|
||||||
|
"""Test snapshot service with os error."""
|
||||||
|
mock_integration(hass, MockModule(domain="test"))
|
||||||
|
mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity(hass)]))
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, image.DOMAIN, {"image": {"platform": "test"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
patch("os.makedirs", side_effect=OSError),
|
||||||
|
pytest.raises(HomeAssistantError),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
image.DOMAIN,
|
||||||
|
image.SERVICE_SNAPSHOT,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "image.test",
|
||||||
|
image.ATTR_FILENAME: "/test/snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue