Add sighthound timestamped file (#32202)

* Update image_processing.py

Adds save timestamp file and adds last_detection attribute

* Update test_image_processing.py

Adds test

* Adds assert pil_img.save.call_args

* Test timestamp filename

* Add test bad data

* Update test_image_processing.py

* Fix bad image data test

* Update homeassistant/components/sighthound/image_processing.py

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Paulus Schoutsen 2020-03-04 17:31:54 -08:00 committed by GitHub
parent e416f17e4d
commit 3ca97a0517
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 111 additions and 9 deletions

View File

@ -3,7 +3,7 @@ import io
import logging import logging
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw from PIL import Image, ImageDraw, UnidentifiedImageError
import simplehound.core as hound import simplehound.core as hound
import voluptuous as vol import voluptuous as vol
@ -17,6 +17,7 @@ from homeassistant.components.image_processing import (
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
from homeassistant.core import split_entity_id from homeassistant.core import split_entity_id
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
from homeassistant.util.pil import draw_box from homeassistant.util.pil import draw_box
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,6 +28,8 @@ ATTR_BOUNDING_BOX = "bounding_box"
ATTR_PEOPLE = "people" ATTR_PEOPLE = "people"
CONF_ACCOUNT_TYPE = "account_type" CONF_ACCOUNT_TYPE = "account_type"
CONF_SAVE_FILE_FOLDER = "save_file_folder" CONF_SAVE_FILE_FOLDER = "save_file_folder"
CONF_SAVE_TIMESTAMPTED_FILE = "save_timestamped_file"
DATETIME_FORMAT = "%Y-%m-%d_%H:%M:%S"
DEV = "dev" DEV = "dev"
PROD = "prod" PROD = "prod"
@ -35,6 +38,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string,
vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]), vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]),
vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir, vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir,
vol.Optional(CONF_SAVE_TIMESTAMPTED_FILE, default=False): cv.boolean,
} }
) )
@ -58,7 +62,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
entities = [] entities = []
for camera in config[CONF_SOURCE]: for camera in config[CONF_SOURCE]:
sighthound = SighthoundEntity( sighthound = SighthoundEntity(
api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), save_file_folder api,
camera[CONF_ENTITY_ID],
camera.get(CONF_NAME),
save_file_folder,
config[CONF_SAVE_TIMESTAMPTED_FILE],
) )
entities.append(sighthound) entities.append(sighthound)
add_entities(entities) add_entities(entities)
@ -67,7 +75,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class SighthoundEntity(ImageProcessingEntity): class SighthoundEntity(ImageProcessingEntity):
"""Create a sighthound entity.""" """Create a sighthound entity."""
def __init__(self, api, camera_entity, name, save_file_folder): def __init__(
self, api, camera_entity, name, save_file_folder, save_timestamped_file
):
"""Init.""" """Init."""
self._api = api self._api = api
self._camera = camera_entity self._camera = camera_entity
@ -77,15 +87,19 @@ class SighthoundEntity(ImageProcessingEntity):
camera_name = split_entity_id(camera_entity)[1] camera_name = split_entity_id(camera_entity)[1]
self._name = f"sighthound_{camera_name}" self._name = f"sighthound_{camera_name}"
self._state = None self._state = None
self._last_detection = None
self._image_width = None self._image_width = None
self._image_height = None self._image_height = None
self._save_file_folder = save_file_folder self._save_file_folder = save_file_folder
self._save_timestamped_file = save_timestamped_file
def process_image(self, image): def process_image(self, image):
"""Process an image.""" """Process an image."""
detections = self._api.detect(image) detections = self._api.detect(image)
people = hound.get_people(detections) people = hound.get_people(detections)
self._state = len(people) self._state = len(people)
if self._state > 0:
self._last_detection = dt_util.now().strftime(DATETIME_FORMAT)
metadata = hound.get_metadata(detections) metadata = hound.get_metadata(detections)
self._image_width = metadata["image_width"] self._image_width = metadata["image_width"]
@ -109,7 +123,11 @@ class SighthoundEntity(ImageProcessingEntity):
def save_image(self, image, people, directory): def save_image(self, image, people, directory):
"""Save a timestamped image with bounding boxes around targets.""" """Save a timestamped image with bounding boxes around targets."""
try:
img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
except UnidentifiedImageError:
_LOGGER.warning("Sighthound unable to process image, bad data")
return
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
for person in people: for person in people:
@ -117,9 +135,15 @@ class SighthoundEntity(ImageProcessingEntity):
person["boundingBox"], self._image_width, self._image_height person["boundingBox"], self._image_width, self._image_height
) )
draw_box(draw, box, self._image_width, self._image_height) draw_box(draw, box, self._image_width, self._image_height)
latest_save_path = directory / f"{self._name}_latest.jpg" latest_save_path = directory / f"{self._name}_latest.jpg"
img.save(latest_save_path) img.save(latest_save_path)
if self._save_timestamped_file:
timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg"
img.save(timestamp_save_path)
_LOGGER.info("Sighthound saved file %s", timestamp_save_path)
@property @property
def camera_entity(self): def camera_entity(self):
"""Return camera entity id from process pictures.""" """Return camera entity id from process pictures."""
@ -144,3 +168,11 @@ class SighthoundEntity(ImageProcessingEntity):
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement.""" """Return the unit of measurement."""
return ATTR_PEOPLE return ATTR_PEOPLE
@property
def device_state_attributes(self):
"""Return the attributes."""
attr = {}
if self._last_detection:
attr["last_person"] = self._last_detection
return attr

View File

@ -1,8 +1,11 @@
"""Tests for the Sighthound integration.""" """Tests for the Sighthound integration."""
from copy import deepcopy from copy import deepcopy
import datetime
import os import os
from unittest.mock import patch from pathlib import Path
from unittest import mock
from PIL import UnidentifiedImageError
import pytest import pytest
import simplehound.core as hound import simplehound.core as hound
@ -40,11 +43,13 @@ MOCK_DETECTIONS = {
"requestId": "545cec700eac4d389743e2266264e84b", "requestId": "545cec700eac4d389743e2266264e84b",
} }
MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3)
@pytest.fixture @pytest.fixture
def mock_detections(): def mock_detections():
"""Return a mock detection.""" """Return a mock detection."""
with patch( with mock.patch(
"simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS "simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS
) as detection: ) as detection:
yield detection yield detection
@ -53,16 +58,35 @@ def mock_detections():
@pytest.fixture @pytest.fixture
def mock_image(): def mock_image():
"""Return a mock camera image.""" """Return a mock camera image."""
with patch( with mock.patch(
"homeassistant.components.demo.camera.DemoCamera.camera_image", "homeassistant.components.demo.camera.DemoCamera.camera_image",
return_value=b"Test", return_value=b"Test",
) as image: ) as image:
yield image yield image
@pytest.fixture
def mock_bad_image_data():
"""Mock bad image data."""
with mock.patch(
"homeassistant.components.sighthound.image_processing.Image.open",
side_effect=UnidentifiedImageError,
) as bad_data:
yield bad_data
@pytest.fixture
def mock_now():
"""Return a mock now datetime."""
with mock.patch("homeassistant.util.dt.now", return_value=MOCK_NOW) as now_dt:
yield now_dt
async def test_bad_api_key(hass, caplog): async def test_bad_api_key(hass, caplog):
"""Catch bad api key.""" """Catch bad api key."""
with patch("simplehound.core.cloud.detect", side_effect=hound.SimplehoundException): with mock.patch(
"simplehound.core.cloud.detect", side_effect=hound.SimplehoundException
):
await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
assert "Sighthound error" in caplog.text assert "Sighthound error" in caplog.text
assert not hass.states.get(VALID_ENTITY_ID) assert not hass.states.get(VALID_ENTITY_ID)
@ -97,6 +121,21 @@ async def test_process_image(hass, mock_image, mock_detections):
assert len(person_events) == 2 assert len(person_events) == 2
async def test_catch_bad_image(
hass, caplog, mock_image, mock_detections, mock_bad_image_data
):
"""Process an image."""
valid_config_save_file = deepcopy(VALID_CONFIG)
valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
assert hass.states.get(VALID_ENTITY_ID)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
await hass.async_block_till_done()
assert "Sighthound unable to process image" in caplog.text
async def test_save_image(hass, mock_image, mock_detections): async def test_save_image(hass, mock_image, mock_detections):
"""Save a processed image.""" """Save a processed image."""
valid_config_save_file = deepcopy(VALID_CONFIG) valid_config_save_file = deepcopy(VALID_CONFIG)
@ -104,7 +143,7 @@ async def test_save_image(hass, mock_image, mock_detections):
await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
assert hass.states.get(VALID_ENTITY_ID) assert hass.states.get(VALID_ENTITY_ID)
with patch( with mock.patch(
"homeassistant.components.sighthound.image_processing.Image.open" "homeassistant.components.sighthound.image_processing.Image.open"
) as pil_img_open: ) as pil_img_open:
pil_img = pil_img_open.return_value pil_img = pil_img_open.return_value
@ -115,3 +154,34 @@ async def test_save_image(hass, mock_image, mock_detections):
state = hass.states.get(VALID_ENTITY_ID) state = hass.states.get(VALID_ENTITY_ID)
assert state.state == "2" assert state.state == "2"
assert pil_img.save.call_count == 1 assert pil_img.save.call_count == 1
directory = Path(TEST_DIR)
latest_save_path = directory / "sighthound_demo_camera_latest.jpg"
assert pil_img.save.call_args_list[0] == mock.call(latest_save_path)
async def test_save_timestamped_image(hass, mock_image, mock_detections, mock_now):
"""Save a processed image."""
valid_config_save_ts_file = deepcopy(VALID_CONFIG)
valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True})
await async_setup_component(hass, ip.DOMAIN, valid_config_save_ts_file)
assert hass.states.get(VALID_ENTITY_ID)
with mock.patch(
"homeassistant.components.sighthound.image_processing.Image.open"
) as pil_img_open:
pil_img = pil_img_open.return_value
pil_img = pil_img.convert.return_value
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
await hass.async_block_till_done()
state = hass.states.get(VALID_ENTITY_ID)
assert state.state == "2"
assert pil_img.save.call_count == 2
directory = Path(TEST_DIR)
timestamp_save_path = (
directory / "sighthound_demo_camera_2020-02-20_10:05:03.jpg"
)
assert pil_img.save.call_args_list[1] == mock.call(timestamp_save_path)