diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 0d28fe4c605..8e59ba53958 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -36,6 +36,7 @@ CONF_SOURCE = 'source' CONF_CONFIDENCE = 'confidence' DEFAULT_TIMEOUT = 10 +DEFAULT_CONFIDENCE = 80 SOURCE_SCHEMA = vol.Schema({ vol.Required(CONF_ENTITY_ID): cv.entity_id, @@ -44,6 +45,8 @@ SOURCE_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [SOURCE_SCHEMA]), + vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) SERVICE_SCAN_SCHEMA = vol.Schema({ @@ -95,6 +98,11 @@ class ImageProcessingEntity(Entity): """Return camera entity id from process pictures.""" return None + @property + def confidence(self): + """Return minimum confidence for do some things.""" + return None + def process_image(self, image): """Process image.""" raise NotImplementedError() diff --git a/homeassistant/components/image_processing/demo.py b/homeassistant/components/image_processing/demo.py index 8ba835e8df0..62b1f8bee9b 100644 --- a/homeassistant/components/image_processing/demo.py +++ b/homeassistant/components/image_processing/demo.py @@ -8,13 +8,17 @@ https://home-assistant.io/components/demo/ from homeassistant.components.image_processing import ImageProcessingEntity from homeassistant.components.image_processing.openalpr_local import ( ImageProcessingAlprEntity) +from homeassistant.components.image_processing.microsoft_face_identify import ( + ImageProcessingFaceIdentifyEntity) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the demo image_processing platform.""" add_devices([ DemoImageProcessing('camera.demo_camera', "Demo"), - DemoImageProcessingAlpr('camera.demo_camera', "Demo Alpr") + DemoImageProcessingAlpr('camera.demo_camera', "Demo Alpr"), + DemoImageProcessingFaceIdentify( + 'camera.demo_camera', "Demo Face Identify") ]) @@ -82,3 +86,39 @@ class DemoImageProcessingAlpr(ImageProcessingAlprEntity): } self.process_plates(demo_data, 1) + + +class DemoImageProcessingFaceIdentify(ImageProcessingFaceIdentifyEntity): + """Demo face identify image processing entity.""" + + def __init__(self, camera_entity, name): + """Initialize demo alpr.""" + super().__init__() + + self._name = name + self._camera = camera_entity + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def confidence(self): + """Return minimum confidence for send events.""" + return 80 + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + demo_data = { + 'Hans': 98.34, + 'Helena': 82.53, + 'Luna': 62.53, + } + + self.process_faces(demo_data, 4) diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py new file mode 100644 index 00000000000..c8cb6fc4080 --- /dev/null +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -0,0 +1,191 @@ +""" +Component that will help set the microsoft face for verify processing. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/image_processing.microsoft_face_identify/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import split_entity_id, callback +from homeassistant.const import STATE_UNKNOWN +from homeassistant.exceptions import HomeAssistantError +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE +from homeassistant.components.image_processing import ( + PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE, + CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.async import run_callback_threadsafe + +DEPENDENCIES = ['microsoft_face'] + +_LOGGER = logging.getLogger(__name__) + +EVENT_IDENTIFY_FACE = 'identify_face' + +ATTR_NAME = 'name' +ATTR_TOTAL_FACES = 'total_faces' +ATTR_KNOWN_FACES = 'known_faces' +CONF_GROUP = 'group' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_GROUP): cv.slugify, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the microsoft face identify platform.""" + api = hass.data[DATA_MICROSOFT_FACE] + face_group = config[CONF_GROUP] + confidence = config[CONF_CONFIDENCE] + + entities = [] + for camera in config[CONF_SOURCE]: + entities.append(MicrosoftFaceIdentifyEntity( + camera[CONF_ENTITY_ID], api, face_group, confidence, + camera.get(CONF_NAME) + )) + + yield from async_add_devices(entities) + + +class ImageProcessingFaceIdentifyEntity(ImageProcessingEntity): + """Base entity class for face identify/verify image processing.""" + + def __init__(self): + """Initialize base face identify/verify entity.""" + self.known_faces = {} # last scan data + self.total_faces = 0 # face count + + @property + def state(self): + """Return the state of the entity.""" + confidence = 0 + face_name = STATE_UNKNOWN + + # search high verify face + for i_name, i_co in self.known_faces.items(): + if i_co > confidence: + confidence = i_co + face_name = i_name + return face_name + + @property + def state_attributes(self): + """Return device specific state attributes.""" + attr = { + ATTR_KNOWN_FACES: self.known_faces, + ATTR_TOTAL_FACES: self.total_faces, + } + + return attr + + def process_faces(self, known, total): + """Send event with detected faces and store data.""" + run_callback_threadsafe( + self.hass.loop, self.async_process_faces, known, total + ).result() + + @callback + def async_process_faces(self, known, total): + """Send event with detected faces and store data. + + known are a dict in follow format: + { 'name': confidence } + + This method must be run in the event loop. + """ + detect = {name: confidence for name, confidence in known.items() + if confidence >= self.confidence} + + # send events + for name, confidence in detect.items(): + self.hass.async_add_job( + self.hass.bus.async_fire, EVENT_IDENTIFY_FACE, { + ATTR_NAME: name, + ATTR_ENTITY_ID: self.entity_id, + ATTR_CONFIDENCE: confidence, + } + ) + + # update entity store + self.known_faces = detect + self.total_faces = total + + +class MicrosoftFaceIdentifyEntity(ImageProcessingFaceIdentifyEntity): + """Microsoft face api entity for identify.""" + + def __init__(self, camera_entity, api, face_group, confidence, name=None): + """Initialize openalpr local api.""" + super().__init__() + + self._api = api + self._camera = camera_entity + self._confidence = confidence + self._face_group = face_group + + if name: + self._name = name + else: + self._name = "MicrosoftFace {0}".format( + split_entity_id(camera_entity)[1]) + + @property + def confidence(self): + """Return minimum confidence for send events.""" + return self._confidence + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @asyncio.coroutine + def async_process_image(self, image): + """Process image. + + This method is a coroutine. + """ + detect = None + try: + face_data = yield from self._api.call_api( + 'post', 'detect', image, binary=True) + + face_ids = [data['faceId'] for data in face_data] + + detect = yield from self._api.call_api( + 'post', 'identify', + {'faceIds': face_ids, 'personGroupId': self._face_group}) + + except HomeAssistantError as err: + _LOGGER.error("Can't process image on microsoft face: %s", err) + return + + # parse data + knwon_faces = {} + total = 0 + for face in detect: + total += 1 + if len(face['candidates']) == 0: + continue + + data = face['candidates'][0] + name = '' + for s_name, s_id in self._api.store[self._face_group].items(): + if data['personId'] == s_id: + name = s_name + break + + knwon_faces[name] = data['confidence'] * 100 + + # process data + self.async_process_faces(knwon_faces, total) diff --git a/homeassistant/components/image_processing/openalpr_cloud.py b/homeassistant/components/image_processing/openalpr_cloud.py index d17291df07f..7c7d26ce724 100644 --- a/homeassistant/components/image_processing/openalpr_cloud.py +++ b/homeassistant/components/image_processing/openalpr_cloud.py @@ -41,14 +41,11 @@ OPENALPR_REGIONS = [ ] CONF_REGION = 'region' -DEFAULT_CONFIDENCE = 80 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), - vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index 65c2a683341..319f14c1f3d 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -50,14 +50,11 @@ CONF_REGION = 'region' CONF_ALPR_BIN = 'alp_bin' DEFAULT_BINARY = 'alpr' -DEFAULT_CONFIDENCE = 80 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), vol.Optional(CONF_ALPR_BIN, default=DEFAULT_BINARY): cv.string, - vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) @@ -84,11 +81,6 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): self.plates = {} # last scan data self.vehicles = 0 # vehicles count - @property - def confidence(self): - """Return minimum confidence for send events.""" - return None - @property def state(self): """Return the state of the entity.""" diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py new file mode 100644 index 00000000000..3f4483e83c3 --- /dev/null +++ b/homeassistant/components/microsoft_face.py @@ -0,0 +1,388 @@ +""" +Support for microsoft face recognition. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/microsoft_face/ +""" +import asyncio +import json +import logging +import os + +import aiohttp +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT +from homeassistant.config import load_yaml_config_file +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.loader import get_component +from homeassistant.util import slugify + +DOMAIN = 'microsoft_face' +DEPENDENCIES = ['camera'] + +_LOGGER = logging.getLogger(__name__) + +FACE_API_URL = "https://westus.api.cognitive.microsoft.com/face/v1.0/{0}" + +DATA_MICROSOFT_FACE = 'microsoft_face' + +SERVICE_CREATE_GROUP = 'create_group' +SERVICE_DELETE_GROUP = 'delete_group' +SERVICE_TRAIN_GROUP = 'train_group' +SERVICE_CREATE_PERSON = 'create_person' +SERVICE_DELETE_PERSON = 'delete_person' +SERVICE_FACE_PERSON = 'face_person' + +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_NAME = 'name' + +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_GROUP_SERVICE = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, +}) + +SCHEMA_PERSON_SERVICE = SCHEMA_GROUP_SERVICE.extend({ + vol.Required(ATTR_GROUP): cv.slugify, +}) + +SCHEMA_FACE_SERVICE = vol.Schema({ + vol.Required(ATTR_PERSON): cv.string, + vol.Required(ATTR_GROUP): cv.slugify, + vol.Required(ATTR_CAMERA_ENTITY): cv.entity_id, +}) + +SCHEMA_TRAIN_SERVICE = vol.Schema({ + vol.Required(ATTR_GROUP): cv.slugify, +}) + + +def create_group(hass, name): + """Create a new person group.""" + data = {ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_CREATE_GROUP, data) + + +def delete_group(hass, name): + """Delete a person group.""" + data = {ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_DELETE_GROUP, data) + + +def train_group(hass, group): + """Train a person group.""" + data = {ATTR_GROUP: group} + hass.services.call(DOMAIN, SERVICE_TRAIN_GROUP, data) + + +def create_person(hass, group, name): + """Create a person in a group.""" + data = {ATTR_GROUP: group, ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_CREATE_PERSON, data) + + +def delete_person(hass, group, name): + """Delete a person in a group.""" + data = {ATTR_GROUP: group, ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_DELETE_PERSON, data) + + +def face_person(hass, group, person, camera_entity): + """Add a new face picture to a person.""" + data = {ATTR_GROUP: group, ATTR_PERSON: person, + ATTR_CAMERA_ENTITY: camera_entity} + hass.services.call(DOMAIN, SERVICE_FACE_PERSON, data) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup microsoft face.""" + entities = {} + face = MicrosoftFace( + hass, + config[DOMAIN].get(CONF_API_KEY), + config[DOMAIN].get(CONF_TIMEOUT), + entities + ) + + try: + # read exists group/person from cloud and create entities + yield from face.update_store() + except HomeAssistantError as err: + _LOGGER.error("Can't load data from face api: %s", err) + return False + + hass.data[DATA_MICROSOFT_FACE] = face + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_create_group(service): + """Create a new person group.""" + name = service.data[ATTR_NAME] + g_id = slugify(name) + + try: + yield from face.call_api( + 'put', "persongroups/{0}".format(g_id), {'name': name}) + face.store[g_id] = {} + + entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_CREATE_GROUP, async_create_group, + descriptions[DOMAIN].get(SERVICE_CREATE_GROUP), + schema=SCHEMA_GROUP_SERVICE) + + @asyncio.coroutine + def async_delete_group(service): + """Delete a person group.""" + g_id = slugify(service.data[ATTR_NAME]) + + try: + yield from face.call_api('delete', "persongroups/{0}".format(g_id)) + face.store.pop(g_id) + + entity = entities.pop(g_id) + yield from entity.async_remove() + except HomeAssistantError as err: + _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_DELETE_GROUP, async_delete_group, + descriptions[DOMAIN].get(SERVICE_DELETE_GROUP), + schema=SCHEMA_GROUP_SERVICE) + + @asyncio.coroutine + def async_train_group(service): + """Train a person group.""" + g_id = service.data[ATTR_GROUP] + + try: + yield from face.call_api( + 'post', "persongroups/{0}/train".format(g_id)) + except HomeAssistantError as err: + _LOGGER.error("Can't train group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_TRAIN_GROUP, async_train_group, + descriptions[DOMAIN].get(SERVICE_TRAIN_GROUP), + schema=SCHEMA_TRAIN_SERVICE) + + @asyncio.coroutine + def async_create_person(service): + """Create a person in a group.""" + name = service.data[ATTR_NAME] + g_id = service.data[ATTR_GROUP] + + try: + user_data = yield from face.call_api( + 'post', "persongroups/{0}/persons".format(g_id), {'name': name} + ) + + face.store[g_id][name] = user_data['personId'] + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't create person '%s' with error: %s", name, err) + + hass.services.async_register( + DOMAIN, SERVICE_CREATE_PERSON, async_create_person, + descriptions[DOMAIN].get(SERVICE_CREATE_PERSON), + schema=SCHEMA_PERSON_SERVICE) + + @asyncio.coroutine + def async_delete_person(service): + """Delete a person in a group.""" + name = service.data[ATTR_NAME] + g_id = service.data[ATTR_GROUP] + p_id = face.store[g_id].get(name) + + try: + yield from face.call_api( + 'delete', "persongroups/{0}/persons/{1}".format(g_id, p_id)) + + face.store[g_id].pop(name) + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_DELETE_PERSON, async_delete_person, + descriptions[DOMAIN].get(SERVICE_DELETE_PERSON), + schema=SCHEMA_PERSON_SERVICE) + + @asyncio.coroutine + def async_face_person(service): + """Add a new face picture to a person.""" + g_id = service.data[ATTR_GROUP] + p_id = face.store[g_id].get(service.data[ATTR_PERSON]) + + camera_entity = service.data[ATTR_CAMERA_ENTITY] + camera = get_component('camera') + + try: + image = yield from camera.async_get_image(hass, camera_entity) + + yield from face.call_api( + 'post', + "persongroups/{0}/persons/{1}/persistedFaces".format( + g_id, p_id), + image, + binary=True + ) + except HomeAssistantError as err: + _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_FACE_PERSON, async_face_person, + descriptions[DOMAIN].get(SERVICE_FACE_PERSON), + schema=SCHEMA_FACE_SERVICE) + + return True + + +class MicrosoftFaceGroupEntity(Entity): + """Person-Group state/data Entity.""" + + def __init__(self, hass, api, g_id, name): + """Initialize person/group entity.""" + self.hass = hass + self._api = api + self._id = g_id + self._name = name + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def entity_id(self): + """Return entity id.""" + return "{0}.{1}".format(DOMAIN, self._id) + + @property + def state(self): + """Return the state of the entity.""" + return len(self._api.store[self._id]) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attr = {} + for name, p_id in self._api.store[self._id].items(): + attr[name] = p_id + + return attr + + +class MicrosoftFace(object): + """Microsoft Face api for HomeAssistant.""" + + def __init__(self, hass, api_key, timeout, entities): + """Initialize Microsoft Face api.""" + self.hass = hass + self.websession = async_get_clientsession(hass) + self.timeout = timeout + self._api_key = api_key + self._store = {} + self._entities = entities + + @property + def store(self): + """Store group/person data and IDs.""" + return self._store + + @asyncio.coroutine + def update_store(self): + """Load all group/person data into local store.""" + groups = yield from self.call_api('get', 'persongroups') + + tasks = [] + for group in groups: + g_id = group['personGroupId'] + self._store[g_id] = {} + self._entities[g_id] = MicrosoftFaceGroupEntity( + self.hass, self, g_id, group['name']) + + persons = yield from self.call_api( + 'get', "persongroups/{0}/persons".format(g_id)) + + for person in persons: + self._store[g_id][person['name']] = person['personId'] + + tasks.append(self._entities[g_id].async_update_ha_state()) + + if tasks: + yield from asyncio.wait(tasks, loop=self.hass.loop) + + @asyncio.coroutine + def call_api(self, method, function, data=None, binary=False, + params=None): + """Make a api call.""" + headers = {"Ocp-Apim-Subscription-Key": self._api_key} + url = FACE_API_URL.format(function) + + payload = None + if binary: + headers[CONTENT_TYPE] = "application/octet-stream" + payload = data + else: + headers[CONTENT_TYPE] = "application/json" + if data is not None: + payload = json.dumps(data).encode() + else: + payload = None + + response = None + try: + with async_timeout.timeout(self.timeout, loop=self.hass.loop): + response = yield from getattr(self.websession, method)( + url, data=payload, headers=headers, params=params) + + answer = yield from response.json() + _LOGGER.debug("Read from microsoft face api: %s", answer) + if response.status == 200: + return answer + + _LOGGER.warning("Error %d microsoft face api %s", + response.status, response.url) + raise HomeAssistantError(answer['error']['message']) + + except (aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError): + _LOGGER.warning("Can't connect to microsoft face api") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from microsoft face api %s", response.url) + + finally: + if response is not None: + yield from response.release() + + raise HomeAssistantError("Network error on microsoft face api.") diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index e42cd56cd8c..dea38cb77f8 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -133,17 +133,70 @@ homematic: reconnect: description: Reconnect to all Homematic Hubs. -openalpr: - scan: - description: Scan immediately a device. +microsoft_face: + create_group: + description: Create a new person group. fields: - entity_id: - description: Name(s) of entities to scan - example: 'openalpr.garage' + name: + description: Name of the group + example: 'family' - restart: - description: Restart ffmpeg process of device. + delete_group: + description: Delete a new person group. + + fields: + name: + description: Name of the group + example: 'family' + + train_group: + description: Train a person group. + + fields: + name: + description: Name of the group + example: 'family' + + create_person: + description: Create a new person in the group. + + fields: + name: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + delete_person: + description: Delete a person in the group. + + fields: + name: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + face_person: + description: Add a new picture to a person. + + fields: + name: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + camera_entity: + description: Camera to take a picture + example: camera.door verisure: capture_smartcam: diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index c94064969f1..77cfd19bf92 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -213,3 +213,64 @@ class TestImageProcessingAlpr(object): assert event_data[0]['plate'] == 'AC3829' assert event_data[0]['confidence'] == 98.3 assert event_data[0]['entity_id'] == 'image_processing.demo_alpr' + + +class TestImageProcessingFaceIdentify(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + config = { + ip.DOMAIN: { + 'platform': 'demo' + }, + 'camera': { + 'platform': 'demo' + }, + } + + with patch('homeassistant.components.image_processing.demo.' + 'DemoImageProcessingFaceIdentify.should_poll', + new_callable=PropertyMock(return_value=False)): + setup_component(self.hass, ip.DOMAIN, config) + + state = self.hass.states.get('camera.demo_camera') + self.url = "{0}{1}".format( + self.hass.config.api.base_url, + state.attributes.get(ATTR_ENTITY_PICTURE)) + + self.face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + self.face_events.append(event) + + self.hass.bus.listen('identify_face', mock_face_event) + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_face_event_call(self, aioclient_mock): + """Setup and scan a picture and test faces from event.""" + aioclient_mock.get(self.url, content=b'image') + + ip.scan(self.hass, entity_id='image_processing.demo_face_identify') + self.hass.block_till_done() + + state = self.hass.states.get('image_processing.demo_face_identify') + + assert len(self.face_events) == 2 + assert state.state == 'Hans' + assert state.attributes['total_faces'] == 4 + + event_data = [event.data for event in self.face_events if + event.data.get('name') == 'Hans'] + assert len(event_data) == 1 + assert event_data[0]['name'] == 'Hans' + assert event_data[0]['confidence'] == 98.34 + assert event_data[0]['entity_id'] == \ + 'image_processing.demo_face_identify' diff --git a/tests/components/image_processing/test_microsoft_face_identify.py b/tests/components/image_processing/test_microsoft_face_identify.py new file mode 100644 index 00000000000..8d75f6ff1d3 --- /dev/null +++ b/tests/components/image_processing/test_microsoft_face_identify.py @@ -0,0 +1,163 @@ +"""The tests for the microsoft face identify platform.""" +from unittest.mock import patch, PropertyMock + +from homeassistant.core import callback +from homeassistant.const import ATTR_ENTITY_PICTURE +from homeassistant.bootstrap import setup_component +import homeassistant.components.image_processing as ip +import homeassistant.components.microsoft_face as mf + +from tests.common import ( + get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) + + +class TestMicrosoftFaceIdentifySetup(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_platform(self, store_mock): + """Setup platform with one entity.""" + config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + with assert_setup_component(1, ip.DOMAIN): + setup_component(self.hass, ip.DOMAIN, config) + + assert self.hass.states.get( + 'image_processing.microsoftface_demo_camera') + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_platform_name(self, store_mock): + """Setup platform with one entity and set name.""" + config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera', + 'name': 'test local' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + with assert_setup_component(1, ip.DOMAIN): + setup_component(self.hass, ip.DOMAIN, config) + + assert self.hass.states.get('image_processing.test_local') + + +class TestMicrosoftFaceIdentify(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + self.config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera', + 'name': 'test local' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.image_processing.microsoft_face_identify.' + 'MicrosoftFaceIdentifyEntity.should_poll', + new_callable=PropertyMock(return_value=False)) + def test_openalpr_process_image(self, poll_mock, aioclient_mock): + """Setup and scan a picture and test plates from event.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + setup_component(self.hass, ip.DOMAIN, self.config) + + state = self.hass.states.get('camera.demo_camera') + url = "{0}{1}".format( + self.hass.config.api.base_url, + state.attributes.get(ATTR_ENTITY_PICTURE)) + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + self.hass.bus.listen('identify_face', mock_face_event) + + aioclient_mock.get(url, content=b'image') + + aioclient_mock.post( + mf.FACE_API_URL.format("detect"), + text=load_fixture('microsoft_face_detect.json') + ) + aioclient_mock.post( + mf.FACE_API_URL.format("identify"), + text=load_fixture('microsoft_face_identify.json') + ) + + ip.scan(self.hass, entity_id='image_processing.test_local') + self.hass.block_till_done() + + state = self.hass.states.get('image_processing.test_local') + + assert len(face_events) == 1 + assert state.attributes.get('total_faces') == 2 + assert state.state == 'David' + + assert face_events[0].data['name'] == 'David' + assert face_events[0].data['confidence'] == float(92) + assert face_events[0].data['entity_id'] == \ + 'image_processing.test_local' diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py new file mode 100644 index 00000000000..6dee9dc9a55 --- /dev/null +++ b/tests/components/test_microsoft_face.py @@ -0,0 +1,263 @@ +"""The tests for the microsoft face platform.""" +import asyncio +from unittest.mock import patch + +import homeassistant.components.microsoft_face as mf +from homeassistant.bootstrap import setup_component + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_coro, load_fixture) + + +class TestMicrosoftFaceSetup(object): + """Test the microsoft face component.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + self.config = { + mf.DOMAIN: { + 'api_key': '12345678abcdef', + } + } + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component(self, mock_update): + """Setup component.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component_wrong_api_key(self, mock_update): + """Setup component without api key.""" + with assert_setup_component(0, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, {mf.DOMAIN: {}}) + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component_test_service(self, mock_update): + """Setup component.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert self.hass.services.has_service(mf.DOMAIN, 'create_group') + assert self.hass.services.has_service(mf.DOMAIN, 'delete_group') + assert self.hass.services.has_service(mf.DOMAIN, 'train_group') + assert self.hass.services.has_service(mf.DOMAIN, 'create_person') + assert self.hass.services.has_service(mf.DOMAIN, 'delete_person') + assert self.hass.services.has_service(mf.DOMAIN, 'face_person') + + def test_setup_component_test_entities(self, aioclient_mock): + """Setup component.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + entity_group2 = self.hass.states.get('microsoft_face.test_group2') + + assert entity_group1 is not None + assert entity_group2 is not None + + assert entity_group1.attributes['Ryan'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + assert entity_group1.attributes['David'] == \ + '2ae4935b-9659-44c3-977f-61fac20d0538' + + assert entity_group2.attributes['Ryan'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + assert entity_group2.attributes['David'] == \ + '2ae4935b-9659-44c3-977f-61fac20d0538' + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_groups(self, mock_update, aioclient_mock): + """Setup component, test groups services.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=200, text="{}" + ) + aioclient_mock.delete( + mf.FACE_API_URL.format("persongroups/service_group"), + status=200, text="{}" + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is not None + assert len(aioclient_mock.mock_calls) == 1 + + mf.delete_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 2 + + def test_service_person(self, aioclient_mock): + """Setup component, test person services.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + aioclient_mock.post( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_create_person.json') + ) + aioclient_mock.delete( + mf.FACE_API_URL.format( + "persongroups/test_group1/persons/" + "25985303-c537-4467-b41d-bdb45cd95ca1"), + status=200, text="{}" + ) + + mf.create_person(self.hass, 'test group1', 'Hans') + self.hass.block_till_done() + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + + assert len(aioclient_mock.mock_calls) == 4 + assert entity_group1 is not None + assert entity_group1.attributes['Hans'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + + mf.delete_person(self.hass, 'test group1', 'Hans') + self.hass.block_till_done() + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + + assert len(aioclient_mock.mock_calls) == 5 + assert entity_group1 is not None + assert 'Hans' not in entity_group1.attributes + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_train(self, mock_update, aioclient_mock): + """Setup component, test train groups services.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + aioclient_mock.post( + mf.FACE_API_URL.format("persongroups/service_group/train"), + status=200, text="{}" + ) + + mf.train_group(self.hass, 'Service Group') + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + + @patch('homeassistant.components.camera.async_get_image', + return_value=mock_coro(return_value=b'Test')()) + def test_service_face(self, camera_mock, aioclient_mock): + """Setup component, test person face services.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + self.config['camera'] = {'platform': 'demo'} + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + aioclient_mock.post( + mf.FACE_API_URL.format( + "persongroups/test_group2/persons/" + "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces"), + status=200, text="{}" + ) + + mf.face_person( + self.hass, 'test_group2', 'David', 'camera.demo_camera') + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 4 + assert aioclient_mock.mock_calls[3][2] == b'Test' + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_status_400(self, mock_update, aioclient_mock): + """Setup component, test groups services with error.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=400, text="{'error': {'message': 'Error'}}" + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_status_timeout(self, mock_update, aioclient_mock): + """Setup component, test groups services with timeout.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=400, exc=asyncio.TimeoutError() + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/fixtures/microsoft_face_create_person.json b/tests/fixtures/microsoft_face_create_person.json new file mode 100644 index 00000000000..60e7a826c13 --- /dev/null +++ b/tests/fixtures/microsoft_face_create_person.json @@ -0,0 +1,3 @@ +{ + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1" +} diff --git a/tests/fixtures/microsoft_face_detect.json b/tests/fixtures/microsoft_face_detect.json new file mode 100644 index 00000000000..f9d819da239 --- /dev/null +++ b/tests/fixtures/microsoft_face_detect.json @@ -0,0 +1,27 @@ +[ + { + "faceId": "c5c24a82-6845-4031-9d5d-978df9175426", + "faceRectangle": { + "width": 78, + "height": 78, + "left": 394, + "top": 54 + }, + "faceAttributes": { + "age": 71.0, + "gender": "male", + "smile": 0.88, + "facialHair": { + "mustache": 0.8, + "beard": 0.1, + "sideburns": 0.02 + }, + "glasses": "sunglasses", + "headPose": { + "roll": 2.1, + "yaw": 3, + "pitch": 0 + } + } + } +] diff --git a/tests/fixtures/microsoft_face_identify.json b/tests/fixtures/microsoft_face_identify.json new file mode 100644 index 00000000000..5b106de5324 --- /dev/null +++ b/tests/fixtures/microsoft_face_identify.json @@ -0,0 +1,20 @@ +[ + { + "faceId":"c5c24a82-6845-4031-9d5d-978df9175426", + "candidates":[ + { + "personId":"2ae4935b-9659-44c3-977f-61fac20d0538", + "confidence":0.92 + } + ] + }, + { + "faceId":"c5c24a82-6825-4031-9d5d-978df0175426", + "candidates":[ + { + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1", + "confidence":0.32 + } + ] + } +] diff --git a/tests/fixtures/microsoft_face_persongroups.json b/tests/fixtures/microsoft_face_persongroups.json new file mode 100644 index 00000000000..0eb0722a550 --- /dev/null +++ b/tests/fixtures/microsoft_face_persongroups.json @@ -0,0 +1,12 @@ +[ + { + "personGroupId":"test_group1", + "name":"test group1", + "userData":"test" + }, + { + "personGroupId":"test_group2", + "name":"test group2", + "userData":"test" + } +] diff --git a/tests/fixtures/microsoft_face_persons.json b/tests/fixtures/microsoft_face_persons.json new file mode 100644 index 00000000000..05da6816023 --- /dev/null +++ b/tests/fixtures/microsoft_face_persons.json @@ -0,0 +1,21 @@ +[ + { + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1", + "name":"Ryan", + "userData":"User-provided data attached to the person", + "persistedFaceIds":[ + "015839fb-fbd9-4f79-ace9-7675fc2f1dd9", + "fce92aed-d578-4d2e-8114-068f8af4492e", + "b64d5e15-8257-4af2-b20a-5a750f8940e7" + ] + }, + { + "personId":"2ae4935b-9659-44c3-977f-61fac20d0538", + "name":"David", + "userData":"User-provided data attached to the person", + "persistedFaceIds":[ + "30ea1073-cc9e-4652-b1e3-d08fb7b95315", + "fbd2a038-dbff-452c-8e79-2ee81b1aa84e" + ] + } +]