diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py new file mode 100644 index 00000000000..2e8b10c457d --- /dev/null +++ b/homeassistant/components/person/__init__.py @@ -0,0 +1,145 @@ +""" +Support for tracking people. + +For more details about this component, please refer to the documentation. +https://home-assistant.io/components/person/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.const import ( + ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ID, CONF_NAME) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) +ATTR_SOURCE = 'source' +ATTR_USER_ID = 'user_id' +CONF_DEVICE_TRACKERS = 'device_trackers' +CONF_USER_ID = 'user_id' +DOMAIN = 'person' + +PERSON_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_USER_ID): cv.string, + vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All( + cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [PERSON_SCHEMA]) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the person component.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + conf = config[DOMAIN] + entities = [] + for person_conf in conf: + user_id = person_conf.get(CONF_USER_ID) + if (user_id is not None + and await hass.auth.async_get_user(user_id) is None): + _LOGGER.error( + "Invalid user_id detected for person %s", + person_conf[CONF_NAME]) + continue + entities.append(Person(person_conf, user_id)) + + if not entities: + _LOGGER.error("No persons could be set up") + return False + + await component.async_add_entities(entities) + + return True + + +class Person(RestoreEntity): + """Represent a tracked person.""" + + def __init__(self, config, user_id): + """Set up person.""" + self._id = config[CONF_ID] + self._latitude = None + self._longitude = None + self._name = config[CONF_NAME] + self._source = None + self._state = None + self._trackers = config.get(CONF_DEVICE_TRACKERS) + self._user_id = user_id + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def state(self): + """Return the state of the person.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes of the person.""" + data = {} + data[ATTR_ID] = self._id + if self._latitude is not None: + data[ATTR_LATITUDE] = round(self._latitude, 5) + if self._longitude is not None: + data[ATTR_LONGITUDE] = round(self._longitude, 5) + if self._source is not None: + data[ATTR_SOURCE] = self._source + if self._user_id is not None: + data[ATTR_USER_ID] = self._user_id + return data + + @property + def unique_id(self): + """Return a unique ID for the person.""" + return self._id + + async def async_added_to_hass(self): + """Register device trackers.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + self._parse_source_state(state) + + if not self._trackers: + return + + @callback + def async_handle_tracker_update(entity, old_state, new_state): + """Handle the device tracker state changes.""" + self._parse_source_state(new_state) + self.async_schedule_update_ha_state() + + _LOGGER.debug( + "Subscribe to device trackers for %s", self.entity_id) + + for tracker in self._trackers: + async_track_state_change( + self.hass, tracker, async_handle_tracker_update) + + def _parse_source_state(self, state): + """Parse source state and set person attributes.""" + self._state = state.state + self._source = state.entity_id + self._latitude = state.attributes.get(ATTR_LATITUDE) + self._longitude = state.attributes.get(ATTR_LONGITUDE) diff --git a/tests/components/person/__init__.py b/tests/components/person/__init__.py new file mode 100644 index 00000000000..217189a78a9 --- /dev/null +++ b/tests/components/person/__init__.py @@ -0,0 +1 @@ +"""The tests for the person component.""" diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py new file mode 100644 index 00000000000..4b10846ee3c --- /dev/null +++ b/tests/components/person/test_init.py @@ -0,0 +1,186 @@ +"""The tests for the person component.""" +from homeassistant.components.person import ATTR_SOURCE, ATTR_USER_ID, DOMAIN +from homeassistant.const import ( + ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN) +from homeassistant.core import CoreState, State +from homeassistant.setup import async_setup_component + +from tests.common import mock_component, mock_restore_cache + +DEVICE_TRACKER = 'device_tracker.test_tracker' +DEVICE_TRACKER_2 = 'device_tracker.test_tracker_2' + + +async def test_minimal_setup(hass): + """Test minimal config with only name.""" + config = {DOMAIN: {'id': '1234', 'name': 'test person'}} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.test_person') + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_LATITUDE) is None + 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 + + +async def test_setup_no_id(hass): + """Test config with no id.""" + config = {DOMAIN: {'name': 'test user'}} + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_setup_no_name(hass): + """Test config with no name.""" + config = {DOMAIN: {'id': '1234'}} + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_setup_user_id(hass, hass_owner_user): + """Test config with user id.""" + user_id = hass_owner_user.id + config = { + DOMAIN: {'id': '1234', 'name': 'test person', 'user_id': user_id}} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.test_person') + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + +async def test_setup_invalid_user_id(hass): + """Test config with invalid user id.""" + config = { + DOMAIN: { + 'id': '1234', 'name': 'test bad user', 'user_id': 'bad_user_id'}} + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_valid_invalid_user_ids(hass, hass_owner_user): + """Test a person with valid user id and a person with invalid user id .""" + user_id = hass_owner_user.id + config = {DOMAIN: [ + {'id': '1234', 'name': 'test valid user', 'user_id': user_id}, + {'id': '5678', 'name': 'test bad user', 'user_id': 'bad_user_id'}]} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.test_valid_user') + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + state = hass.states.get('person.test_bad_user') + assert state is None + + +async def test_setup_tracker(hass, hass_owner_user): + """Test set up person with one device tracker.""" + user_id = hass_owner_user.id + config = {DOMAIN: { + 'id': '1234', 'name': 'tracked person', 'user_id': user_id, + 'device_trackers': DEVICE_TRACKER}} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.tracked_person') + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.states.async_set(DEVICE_TRACKER, 'home') + await hass.async_block_till_done() + + state = hass.states.get('person.tracked_person') + assert state.state == 'home' + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.states.async_set( + DEVICE_TRACKER, 'not_home', + {ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456}) + await hass.async_block_till_done() + + state = hass.states.get('person.tracked_person') + assert state.state == 'not_home' + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) == 10.12346 + assert state.attributes.get(ATTR_LONGITUDE) == 11.12346 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + + +async def test_setup_two_trackers(hass, hass_owner_user): + """Test set up person with two device trackers.""" + user_id = hass_owner_user.id + config = {DOMAIN: { + 'id': '1234', 'name': 'tracked person', 'user_id': user_id, + 'device_trackers': [DEVICE_TRACKER, DEVICE_TRACKER_2]}} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.tracked_person') + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.states.async_set(DEVICE_TRACKER, 'home') + await hass.async_block_till_done() + + state = hass.states.get('person.tracked_person') + assert state.state == 'home' + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.states.async_set( + DEVICE_TRACKER_2, 'not_home', + {ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456}) + await hass.async_block_till_done() + + state = hass.states.get('person.tracked_person') + assert state.state == 'not_home' + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) == 12.12346 + assert state.attributes.get(ATTR_LONGITUDE) == 13.12346 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 + assert state.attributes.get(ATTR_USER_ID) == user_id + + +async def test_restore_home_state(hass, hass_owner_user): + """Test that the state is restored for a person on startup.""" + user_id = hass_owner_user.id + attrs = { + ATTR_ID: '1234', ATTR_LATITUDE: 10.12346, ATTR_LONGITUDE: 11.12346, + ATTR_SOURCE: DEVICE_TRACKER, ATTR_USER_ID: user_id} + state = State('person.tracked_person', 'home', attrs) + mock_restore_cache(hass, (state, )) + hass.state = CoreState.starting + mock_component(hass, 'recorder') + config = {DOMAIN: { + 'id': '1234', 'name': 'tracked person', 'user_id': user_id, + 'device_trackers': DEVICE_TRACKER}} + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get('person.tracked_person') + assert state.state == 'home' + assert state.attributes.get(ATTR_ID) == '1234' + assert state.attributes.get(ATTR_LATITUDE) == 10.12346 + assert state.attributes.get(ATTR_LONGITUDE) == 11.12346 + # 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