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 uppull/20993/head
parent
32f2221b22
commit
5f76628665
|
@ -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)
|
|
@ -0,0 +1 @@
|
|||
"""The tests for the person component."""
|
|
@ -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
|
Loading…
Reference in New Issue