diff --git a/CODEOWNERS b/CODEOWNERS index 472e0ae730d..f497b64d7ae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index f363f46d2d7..7f75a5e8897 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -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": [] } diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py new file mode 100644 index 00000000000..a8110decd0a --- /dev/null +++ b/homeassistant/components/image/__init__.py @@ -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]) diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py new file mode 100644 index 00000000000..5241f7ec07b --- /dev/null +++ b/homeassistant/components/image/const.py @@ -0,0 +1,3 @@ +"""Constants for the Image integration.""" + +DOMAIN = "image" diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json new file mode 100644 index 00000000000..4fc9c2d1d05 --- /dev/null +++ b/homeassistant/components/image/manifest.json @@ -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"] +} diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49f1b45f2ed..584ce708d15 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -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. diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index bd1dfa6b588..7aec7df7c9a 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -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" diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index ffa659f979e..081645a4aa8 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -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": [] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index eaa813cae95..00d528ba399 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -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": [] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 352b96b5f22..4996ba29f83 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -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"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 1ad7effdf0e..a5c56b37778 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -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"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index fc87b5cdbff..ddb0ec542ba 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -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": [] } diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 06c86d3aa1c..9e7c6061987 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -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, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 58d3e3be883..9a8efbe2bd6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index c2527888b73..58fdb388958 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b8b4019fbd..c035d938e5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/image/__init__.py b/tests/components/image/__init__.py new file mode 100644 index 00000000000..8bf90c4f516 --- /dev/null +++ b/tests/components/image/__init__.py @@ -0,0 +1 @@ +"""Tests for the Image integration.""" diff --git a/tests/components/image/logo.png b/tests/components/image/logo.png new file mode 100644 index 00000000000..4fe49f12630 Binary files /dev/null and b/tests/components/image/logo.png differ diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py new file mode 100644 index 00000000000..277e6f07149 --- /dev/null +++ b/tests/components/image/test_init.py @@ -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() diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 887f0d94fef..64aa583e2f5 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -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"