Add image integration (#38969)

pull/39036/head
Paulus Schoutsen 2020-08-19 11:33:04 +02:00 committed by GitHub
parent 111c2006c8
commit 24a16ff8fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 356 additions and 41 deletions

View File

@ -195,6 +195,7 @@ homeassistant/components/iammeter/* @lewei50
homeassistant/components/iaqualink/* @flz
homeassistant/components/icloud/* @Quentame
homeassistant/components/ign_sismologia/* @exxamalte
homeassistant/components/image/* @home-assistant/core
homeassistant/components/incomfort/* @zxdavb
homeassistant/components/influxdb/* @fabaff @mdegat01
homeassistant/components/input_boolean/* @home-assistant/core

View File

@ -2,6 +2,6 @@
"domain": "doods",
"name": "DOODS - Distributed Outside Object Detection Service",
"documentation": "https://www.home-assistant.io/integrations/doods",
"requirements": ["pydoods==1.0.2", "pillow==7.1.2"],
"requirements": ["pydoods==1.0.2", "pillow==7.2.0"],
"codeowners": []
}

View File

@ -0,0 +1,204 @@
"""The Picture integration."""
import asyncio
import logging
import pathlib
import secrets
import shutil
import typing
from PIL import Image, ImageOps, UnidentifiedImageError
from aiohttp import hdrs, web
from aiohttp.web_request import FileField
import voluptuous as vol
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection
from homeassistant.helpers.storage import Store
import homeassistant.util.dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
VALID_SIZES = {256, 512}
MAX_SIZE = 1024 * 1024 * 10
CREATE_FIELDS = {
vol.Required("file"): FileField,
}
UPDATE_FIELDS = {
vol.Optional("name"): vol.All(str, vol.Length(min=1)),
}
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Image integration."""
image_dir = pathlib.Path(hass.config.path(DOMAIN))
hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir)
await storage_collection.async_load()
collection.StorageCollectionWebsocket(
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS,
).async_setup(hass, create_create=False)
hass.http.register_view(ImageUploadView)
hass.http.register_view(ImageServeView(image_dir, storage_collection))
return True
class ImageStorageCollection(collection.StorageCollection):
"""Image collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
def __init__(self, hass: HomeAssistant, image_dir: pathlib.Path) -> None:
"""Initialize media storage collection."""
super().__init__(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
)
self.async_add_listener(self._change_listener)
self.image_dir = image_dir
async def _process_create_data(self, data: typing.Dict) -> typing.Dict:
"""Validate the config is valid."""
data = self.CREATE_SCHEMA(dict(data))
uploaded_file: FileField = data["file"]
if not uploaded_file.content_type.startswith("image/"):
raise vol.Invalid("Only images are allowed")
data[CONF_ID] = secrets.token_hex(16)
data["filesize"] = await self.hass.async_add_executor_job(self._move_data, data)
data["content_type"] = uploaded_file.content_type
data["name"] = uploaded_file.filename
data["uploaded_at"] = dt_util.utcnow().isoformat()
return data
def _move_data(self, data):
"""Move data."""
uploaded_file: FileField = data.pop("file")
# Verify we can read the image
try:
image = Image.open(uploaded_file.file)
except UnidentifiedImageError:
raise vol.Invalid("Unable to identify image file")
# Reset content
uploaded_file.file.seek(0)
media_folder: pathlib.Path = (self.image_dir / data[CONF_ID])
media_folder.mkdir(parents=True)
media_file = media_folder / "original"
# Raises if path is no longer relative to the media dir
media_file.relative_to(media_folder)
_LOGGER.debug("Storing file %s", media_file)
with media_file.open("wb") as target:
shutil.copyfileobj(uploaded_file.file, target)
image.close()
return media_file.stat().st_size
@callback
def _get_suggested_id(self, info: typing.Dict) -> str:
"""Suggest an ID based on the config."""
return info[CONF_ID]
async def _update_data(self, data: dict, update_data: typing.Dict) -> typing.Dict:
"""Return a new updated data object."""
return {**data, **self.UPDATE_SCHEMA(update_data)}
async def _change_listener(self, change_type, item_id, data):
"""Handle change."""
if change_type != collection.CHANGE_REMOVED:
return
await self.hass.async_add_executor_job(shutil.rmtree, self.image_dir / item_id)
class ImageUploadView(HomeAssistantView):
"""View to upload images."""
url = "/api/image/upload"
name = "api:image:upload"
async def post(self, request):
"""Handle upload."""
# Increase max payload
request._client_max_size = MAX_SIZE # pylint: disable=protected-access
data = await request.post()
item = await request.app["hass"].data[DOMAIN].async_create_item(data)
return self.json(item)
class ImageServeView(HomeAssistantView):
"""View to download images."""
url = "/api/image/serve/{image_id}/{filename}"
name = "api:image:serve"
requires_auth = False
def __init__(
self, image_folder: pathlib.Path, image_collection: ImageStorageCollection
):
"""Initialize image serve view."""
self.transform_lock = asyncio.Lock()
self.image_folder = image_folder
self.image_collection = image_collection
async def get(self, request: web.Request, image_id: str, filename: str):
"""Serve image."""
image_size = filename.split("-", 1)[0]
try:
parts = image_size.split("x", 1)
width = int(parts[0])
height = int(parts[1])
except (ValueError, IndexError):
raise web.HTTPBadRequest
if not width or width != height or width not in VALID_SIZES:
raise web.HTTPBadRequest
image_info = self.image_collection.data.get(image_id)
if image_info is None:
raise web.HTTPNotFound()
hass = request.app["hass"]
target_file = self.image_folder / image_id / f"{width}x{height}"
if not target_file.is_file():
async with self.transform_lock:
# Another check in case another request already finished it while waiting
if not target_file.is_file():
await hass.async_add_executor_job(
_generate_thumbnail,
self.image_folder / image_id / "original",
image_info["content_type"],
target_file,
(width, height),
)
return web.FileResponse(
target_file, headers={hdrs.CONTENT_TYPE: image_info["content_type"]}
)
def _generate_thumbnail(original_path, content_type, target_path, target_size):
"""Generate a size."""
image = ImageOps.exif_transpose(Image.open(original_path))
image.thumbnail(target_size)
image.save(target_path, format=content_type.split("/", 1)[1])

View File

@ -0,0 +1,3 @@
"""Constants for the Image integration."""
DOMAIN = "image"

View File

@ -0,0 +1,12 @@
{
"domain": "image",
"name": "Image",
"config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/image",
"requirements": ["pillow==7.2.0"],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": ["http"],
"codeowners": ["@home-assistant/core"]
}

View File

@ -57,6 +57,7 @@ ATTR_USER_ID = "user_id"
CONF_DEVICE_TRACKERS = "device_trackers"
CONF_USER_ID = "user_id"
CONF_PICTURE = "picture"
DOMAIN = "person"
@ -73,6 +74,7 @@ PERSON_SCHEMA = vol.Schema(
vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All(
cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
),
vol.Optional(CONF_PICTURE): cv.string,
}
)
@ -129,6 +131,7 @@ CREATE_FIELDS = {
vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
),
vol.Optional(CONF_PICTURE): vol.Any(str, None),
}
@ -138,6 +141,7 @@ UPDATE_FIELDS = {
vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
),
vol.Optional(CONF_PICTURE): vol.Any(str, None),
}
@ -372,6 +376,11 @@ class Person(RestoreEntity):
"""Return the name of the entity."""
return self._config[CONF_NAME]
@property
def entity_picture(self) -> Optional[str]:
"""Return entity picture."""
return self._config.get(CONF_PICTURE)
@property
def should_poll(self):
"""Return True if entity has to be polled for state.

View File

@ -2,6 +2,7 @@
"domain": "person",
"name": "Person",
"documentation": "https://www.home-assistant.io/integrations/person",
"dependencies": ["image"],
"after_dependencies": ["device_tracker"],
"codeowners": [],
"quality_scale": "internal"

View File

@ -2,6 +2,6 @@
"domain": "proxy",
"name": "Camera Proxy",
"documentation": "https://www.home-assistant.io/integrations/proxy",
"requirements": ["pillow==7.1.2"],
"requirements": ["pillow==7.2.0"],
"codeowners": []
}

View File

@ -2,6 +2,6 @@
"domain": "qrcode",
"name": "QR Code",
"documentation": "https://www.home-assistant.io/integrations/qrcode",
"requirements": ["pillow==7.1.2", "pyzbar==0.1.7"],
"requirements": ["pillow==7.2.0", "pyzbar==0.1.7"],
"codeowners": []
}

View File

@ -2,6 +2,6 @@
"domain": "seven_segments",
"name": "Seven Segments OCR",
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"requirements": ["pillow==7.1.2"],
"requirements": ["pillow==7.2.0"],
"codeowners": ["@fabaff"]
}

View File

@ -2,6 +2,6 @@
"domain": "sighthound",
"name": "Sighthound",
"documentation": "https://www.home-assistant.io/integrations/sighthound",
"requirements": ["pillow==7.1.2", "simplehound==0.3"],
"requirements": ["pillow==7.2.0", "simplehound==0.3"],
"codeowners": ["@robmarkcole"]
}

View File

@ -9,7 +9,7 @@
"pycocotools==2.0.1",
"numpy==1.19.1",
"protobuf==3.12.2",
"pillow==7.1.2"
"pillow==7.2.0"
],
"codeowners": []
}

View File

@ -353,7 +353,13 @@ class StorageCollectionWebsocket:
return f"{self.model_name}_id"
@callback
def async_setup(self, hass: HomeAssistant, *, create_list: bool = True) -> None:
def async_setup(
self,
hass: HomeAssistant,
*,
create_list: bool = True,
create_create: bool = True,
) -> None:
"""Set up the websocket commands."""
if create_list:
websocket_api.async_register_command(
@ -365,19 +371,20 @@ class StorageCollectionWebsocket:
),
)
websocket_api.async_register_command(
hass,
f"{self.api_prefix}/create",
websocket_api.require_admin(
websocket_api.async_response(self.ws_create_item)
),
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
**self.create_schema,
vol.Required("type"): f"{self.api_prefix}/create",
}
),
)
if create_create:
websocket_api.async_register_command(
hass,
f"{self.api_prefix}/create",
websocket_api.require_admin(
websocket_api.async_response(self.ws_create_item)
),
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
**self.create_schema,
vol.Required("type"): f"{self.api_prefix}/create",
}
),
)
websocket_api.async_register_command(
hass,

View File

@ -18,6 +18,7 @@ importlib-metadata==1.6.0;python_version<'3.8'
jinja2>=2.11.1
netdisco==2.8.2
paho-mqtt==1.5.0
pillow==7.2.0
pip>=8.0.3
python-slugify==4.0.1
pytz>=2020.1

View File

@ -1081,12 +1081,13 @@ piglow==1.2.4
pilight==0.1.1
# homeassistant.components.doods
# homeassistant.components.image
# homeassistant.components.proxy
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
pillow==7.1.2
pillow==7.2.0
# homeassistant.components.dominos
pizzapi==0.0.3

View File

@ -501,12 +501,13 @@ pexpect==4.6.0
pilight==0.1.1
# homeassistant.components.doods
# homeassistant.components.image
# homeassistant.components.proxy
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
pillow==7.1.2
pillow==7.2.0
# homeassistant.components.plex
plexapi==4.0.0

View File

@ -0,0 +1 @@
"""Tests for the Image integration."""

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,76 @@
"""Test that we can upload images."""
import pathlib
import tempfile
from aiohttp import ClientSession, ClientWebSocketResponse
from homeassistant.components.websocket_api import const as ws_const
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as util_dt
from tests.async_mock import patch
async def test_upload_image(hass, hass_client, hass_ws_client):
"""Test we can upload an image."""
now = util_dt.utcnow()
test_image = pathlib.Path(__file__).parent / "logo.png"
with tempfile.TemporaryDirectory() as tempdir, patch.object(
hass.config, "path", return_value=tempdir
), patch("homeassistant.util.dt.utcnow", return_value=now):
assert await async_setup_component(hass, "image", {})
ws_client: ClientWebSocketResponse = await hass_ws_client()
client: ClientSession = await hass_client()
with test_image.open("rb") as fp:
res = await client.post("/api/image/upload", data={"file": fp})
assert res.status == 200
item = await res.json()
assert item["content_type"] == "image/png"
assert item["filesize"] == 38847
assert item["name"] == "logo.png"
assert item["uploaded_at"] == now.isoformat()
tempdir = pathlib.Path(tempdir)
item_folder: pathlib.Path = tempdir / item["id"]
assert (item_folder / "original").read_bytes() == test_image.read_bytes()
# fetch non-existing image
res = await client.get("/api/image/serve/non-existing/256x256")
assert res.status == 404
# fetch invalid sizes
for inv_size in ("256", "256x25A", "100x100", "25Ax256"):
res = await client.get(f"/api/image/serve/{item['id']}/{inv_size}")
assert res.status == 400
# fetch resized version
res = await client.get(f"/api/image/serve/{item['id']}/256x256")
assert res.status == 200
assert (item_folder / "256x256").is_file()
# List item
await ws_client.send_json({"id": 6, "type": "image/list"})
msg = await ws_client.receive_json()
assert msg["id"] == 6
assert msg["type"] == ws_const.TYPE_RESULT
assert msg["success"]
assert msg["result"] == [item]
# Delete item
await ws_client.send_json(
{"id": 7, "type": "image/delete", "image_id": item["id"]}
)
msg = await ws_client.receive_json()
assert msg["id"] == 7
assert msg["type"] == ws_const.TYPE_RESULT
assert msg["success"]
# Ensure removed from disk
assert not item_folder.is_dir()

View File

@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.components.person import ATTR_SOURCE, ATTR_USER_ID, DOMAIN
from homeassistant.const import (
ATTR_ENTITY_PICTURE,
ATTR_GPS_ACCURACY,
ATTR_ID,
ATTR_LATITUDE,
@ -24,7 +25,7 @@ from homeassistant.helpers import collection, entity_registry
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
from tests.common import assert_setup_component, mock_component, mock_restore_cache
from tests.common import mock_component, mock_restore_cache
DEVICE_TRACKER = "device_tracker.test_tracker"
DEVICE_TRACKER_2 = "device_tracker.test_tracker_2"
@ -67,8 +68,7 @@ def storage_setup(hass, hass_storage, hass_admin_user):
async def test_minimal_setup(hass):
"""Test minimal config with only name."""
config = {DOMAIN: {"id": "1234", "name": "test person"}}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
@ -76,6 +76,7 @@ async def test_minimal_setup(hass):
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) is None
assert state.attributes.get(ATTR_ENTITY_PICTURE) is None
async def test_setup_no_id(hass):
@ -94,8 +95,7 @@ async def test_setup_user_id(hass, hass_admin_user):
"""Test config with user id."""
user_id = hass_admin_user.id
config = {DOMAIN: {"id": "1234", "name": "test person", "user_id": user_id}}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
@ -115,8 +115,7 @@ async def test_valid_invalid_user_ids(hass, hass_admin_user):
{"id": "5678", "name": "test bad user", "user_id": "bad_user_id"},
]
}
with assert_setup_component(2):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_valid_user")
assert state.state == STATE_UNKNOWN
@ -141,8 +140,7 @@ async def test_setup_tracker(hass, hass_admin_user):
"device_trackers": DEVICE_TRACKER,
}
}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@ -198,8 +196,7 @@ async def test_setup_two_trackers(hass, hass_admin_user):
"device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2],
}
}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@ -285,8 +282,7 @@ async def test_ignore_unavailable_states(hass, hass_admin_user):
"device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2],
}
}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@ -337,10 +333,10 @@ async def test_restore_home_state(hass, hass_admin_user):
"name": "tracked person",
"user_id": user_id,
"device_trackers": DEVICE_TRACKER,
"picture": "/bla",
}
}
with assert_setup_component(1):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == "home"
@ -350,6 +346,7 @@ async def test_restore_home_state(hass, hass_admin_user):
# When restoring state the entity_id of the person will be used as source.
assert state.attributes.get(ATTR_SOURCE) == "person.tracked_person"
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_ENTITY_PICTURE) == "/bla"
async def test_duplicate_ids(hass, hass_admin_user):
@ -360,8 +357,7 @@ async def test_duplicate_ids(hass, hass_admin_user):
{"id": "1234", "name": "test user 2"},
]
}
with assert_setup_component(2):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
assert len(hass.states.async_entity_ids("person")) == 1
assert hass.states.get("person.test_user_1") is not None
@ -371,8 +367,7 @@ async def test_duplicate_ids(hass, hass_admin_user):
async def test_create_person_during_run(hass):
"""Test that person is updated if created while hass is running."""
config = {DOMAIN: {}}
with assert_setup_component(0):
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, DOMAIN, config)
hass.states.async_set(DEVICE_TRACKER, "home")
await hass.async_block_till_done()
@ -465,6 +460,7 @@ async def test_ws_create(hass, hass_ws_client, storage_setup, hass_read_only_use
"name": "Hello",
"device_trackers": [DEVICE_TRACKER],
"user_id": hass_read_only_user.id,
"picture": "/bla",
}
)
resp = await client.receive_json()
@ -529,6 +525,7 @@ async def test_ws_update(hass, hass_ws_client, storage_setup):
"name": "Updated Name",
"device_trackers": [DEVICE_TRACKER_2],
"user_id": None,
"picture": "/bla",
}
)
resp = await client.receive_json()
@ -542,6 +539,7 @@ async def test_ws_update(hass, hass_ws_client, storage_setup):
assert persons[0]["name"] == "Updated Name"
assert persons[0]["device_trackers"] == [DEVICE_TRACKER_2]
assert persons[0]["user_id"] is None
assert persons[0]["picture"] == "/bla"
state = hass.states.get("person.tracked_person")
assert state.name == "Updated Name"