* Remove unnecessary exception re-wraps * Preserve exception chains on re-raise We slap "from cause" to almost all possible cases here. In some cases it could conceivably be better to do "from None" if we really want to hide the cause. However those should be in the minority, and "from cause" should be an improvement over the corresponding raise without a "from" in all cases anyway. The only case where we raise from None here is in plex, where the exception for an original invalid SSL cert is not the root cause for failure to validate a newly fetched one. Follow local convention on exception variable names if there is a consistent one, otherwise `err` to match with majority of codebase. * Fix mistaken re-wrap in homematicip_cloud/hap.py Missed the difference between HmipConnectionError and HmipcConnectionError. * Do not hide original error on plex new cert validation error Original is not the cause for the new one, but showing old in the traceback is useful nevertheless.
209 lines
6.6 KiB
Python
209 lines
6.6 KiB
Python
"""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 as err:
|
|
raise vol.Invalid("Unable to identify image file") from err
|
|
|
|
# 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) as err:
|
|
raise web.HTTPBadRequest from err
|
|
|
|
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])
|