Add MVP person component (#20290)

* Add person component

* Required first name.
* Optional last name and user id.
* Optionally track device trackers. Last device tracker state change will
  set state.
* Set device tracker state entity_id as source attribute.
* Set coordinates of device tracker state as state attributes.
* Restore state.

* Parse restored state too

* Clean up

* Add missing property decorator

* Validate source entities as device trackers

* Only use name instead of first and last name

* Add user_id validation

* Add unique_id

* Remove not needed properties

* Uniform docstrings

* Fail component setup if no valid entities

* Add tests

* Add id and use that for unique_id

* Clean up
pull/20993/head
Martin Hjelmare 2019-02-08 00:25:30 +01:00 committed by Paulus Schoutsen
parent 32f2221b22
commit 5f76628665
3 changed files with 332 additions and 0 deletions

View File

@ -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)

View File

@ -0,0 +1 @@
"""The tests for the person component."""

View File

@ -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