diff --git a/CODEOWNERS b/CODEOWNERS index 3a68c6c3b34..5cc80797c52 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -295,6 +295,7 @@ homeassistant/components/seventeentrack/* @bachya homeassistant/components/shell_command/* @home-assistant/core homeassistant/components/shiftr/* @fabaff homeassistant/components/shodan/* @fabaff +homeassistant/components/sighthound/* @robmarkcole homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb diff --git a/homeassistant/components/sighthound/__init__.py b/homeassistant/components/sighthound/__init__.py new file mode 100644 index 00000000000..f80e739310e --- /dev/null +++ b/homeassistant/components/sighthound/__init__.py @@ -0,0 +1 @@ +"""The sighthound integration.""" diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py new file mode 100644 index 00000000000..175b1edc4c6 --- /dev/null +++ b/homeassistant/components/sighthound/image_processing.py @@ -0,0 +1,120 @@ +"""Person detection using Sighthound cloud service.""" +import logging + +import simplehound.core as hound +import voluptuous as vol + +from homeassistant.components.image_processing import ( + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingEntity, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY +from homeassistant.core import split_entity_id +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +EVENT_PERSON_DETECTED = "sighthound.person_detected" + +ATTR_BOUNDING_BOX = "bounding_box" +ATTR_PEOPLE = "people" +CONF_ACCOUNT_TYPE = "account_type" +DEV = "dev" +PROD = "prod" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the platform.""" + # Validate credentials by processing image. + api_key = config[CONF_API_KEY] + account_type = config[CONF_ACCOUNT_TYPE] + api = hound.cloud(api_key, account_type) + try: + api.detect(b"Test") + except hound.SimplehoundException as exc: + _LOGGER.error("Sighthound error %s setup aborted", exc) + return + + entities = [] + for camera in config[CONF_SOURCE]: + sighthound = SighthoundEntity( + api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME) + ) + entities.append(sighthound) + add_entities(entities) + + +class SighthoundEntity(ImageProcessingEntity): + """Create a sighthound entity.""" + + def __init__(self, api, camera_entity, name): + """Init.""" + self._api = api + self._camera = camera_entity + if name: + self._name = name + else: + camera_name = split_entity_id(camera_entity)[1] + self._name = f"sighthound_{camera_name}" + self._state = None + self._image_width = None + self._image_height = None + + def process_image(self, image): + """Process an image.""" + detections = self._api.detect(image) + people = hound.get_people(detections) + self._state = len(people) + + metadata = hound.get_metadata(detections) + self._image_width = metadata["image_width"] + self._image_height = metadata["image_height"] + for person in people: + self.fire_person_detected_event(person) + + def fire_person_detected_event(self, person): + """Send event with detected total_persons.""" + self.hass.bus.fire( + EVENT_PERSON_DETECTED, + { + ATTR_ENTITY_ID: self.entity_id, + ATTR_BOUNDING_BOX: hound.bbox_to_tf_style( + person["boundingBox"], self._image_width, self._image_height + ), + }, + ) + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return ATTR_PEOPLE diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json new file mode 100644 index 00000000000..737aa01c21f --- /dev/null +++ b/homeassistant/components/sighthound/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "sighthound", + "name": "Sighthound", + "documentation": "https://www.home-assistant.io/integrations/sighthound", + "requirements": [ + "simplehound==0.3" + ], + "dependencies": [], + "codeowners": [ + "@robmarkcole" + ] +} \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 8639b765bfd..6377a4a3af8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1809,6 +1809,9 @@ sharp_aquos_rc==0.3.2 # homeassistant.components.shodan shodan==1.21.2 +# homeassistant.components.sighthound +simplehound==0.3 + # homeassistant.components.simplepush simplepush==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30f5940ebb0..0d440259ca7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -584,6 +584,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.sentry sentry-sdk==0.13.5 +# homeassistant.components.sighthound +simplehound==0.3 + # homeassistant.components.simplisafe simplisafe-python==6.0.0 diff --git a/tests/components/sighthound/__init__.py b/tests/components/sighthound/__init__.py new file mode 100644 index 00000000000..96e0f549baf --- /dev/null +++ b/tests/components/sighthound/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sighthound integration.""" diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py new file mode 100644 index 00000000000..4548a3a6a35 --- /dev/null +++ b/tests/components/sighthound/test_image_processing.py @@ -0,0 +1,93 @@ +"""Tests for the Sighthound integration.""" +from unittest.mock import patch + +import pytest +import simplehound.core as hound + +import homeassistant.components.image_processing as ip +import homeassistant.components.sighthound.image_processing as sh +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY +from homeassistant.core import callback +from homeassistant.setup import async_setup_component + +VALID_CONFIG = { + ip.DOMAIN: { + "platform": "sighthound", + CONF_API_KEY: "abc123", + ip.CONF_SOURCE: {ip.CONF_ENTITY_ID: "camera.demo_camera"}, + }, + "camera": {"platform": "demo"}, +} + +VALID_ENTITY_ID = "image_processing.sighthound_demo_camera" + +MOCK_DETECTIONS = { + "image": {"width": 960, "height": 480, "orientation": 1}, + "objects": [ + { + "type": "person", + "boundingBox": {"x": 227, "y": 133, "height": 245, "width": 125}, + }, + { + "type": "person", + "boundingBox": {"x": 833, "y": 137, "height": 268, "width": 93}, + }, + ], + "requestId": "545cec700eac4d389743e2266264e84b", +} + + +@pytest.fixture +def mock_detections(): + """Return a mock detection.""" + with patch( + "simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS + ) as detection: + yield detection + + +@pytest.fixture +def mock_image(): + """Return a mock camera image.""" + with patch( + "homeassistant.components.demo.camera.DemoCamera.camera_image", + return_value=b"Test", + ) as image: + yield image + + +async def test_bad_api_key(hass, caplog): + """Catch bad api key.""" + with patch("simplehound.core.cloud.detect", side_effect=hound.SimplehoundException): + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert "Sighthound error" in caplog.text + assert not hass.states.get(VALID_ENTITY_ID) + + +async def test_setup_platform(hass, mock_detections): + """Set up platform with one entity.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + +async def test_process_image(hass, mock_image, mock_detections): + """Process an image.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + person_events = [] + + @callback + def capture_person_event(event): + """Mock event.""" + person_events.append(event) + + hass.bus.async_listen(sh.EVENT_PERSON_DETECTED, capture_person_event) + + 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 len(person_events) == 2