From 987b5cd905b550004a5a6997f6a01cef596ed650 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Feb 2019 10:41:40 -0800 Subject: [PATCH] Person component: add storage and WS commands (#20852) * Forbid duplicate IDs * Allow loading persons from storage * Convert to PersonManager * Add storage support and WS commands to Person component * Convert list command to differentiate types * Allow loading person component without defining persons * Fix cleanups after update/delete * Address comments * Start tracking when HA started --- homeassistant/components/person/__init__.py | 342 +++++++++++++++--- .../components/websocket_api/connection.py | 10 + homeassistant/helpers/entity.py | 2 +- tests/components/person/test_init.py | 263 +++++++++++++- 4 files changed, 554 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 2e8b10c457d..8ad03e3f0ff 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -4,26 +4,37 @@ Support for tracking people. For more details about this component, please refer to the documentation. https://home-assistant.io/components/person/ """ +from collections import OrderedDict import logging +import uuid 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) + ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ID, CONF_NAME, + EVENT_HOMEASSISTANT_START) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.storage import Store from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components import websocket_api +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) +ATTR_EDITABLE = 'editable' ATTR_SOURCE = 'source' ATTR_USER_ID = 'user_id' CONF_DEVICE_TRACKERS = 'device_trackers' CONF_USER_ID = 'user_id' DOMAIN = 'person' +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +SAVE_DELAY = 10 PERSON_SCHEMA = vol.Schema({ vol.Required(CONF_ID): cv.string, @@ -34,30 +45,161 @@ PERSON_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [PERSON_SCHEMA]) + vol.Optional(DOMAIN): vol.Any(vol.All(cv.ensure_list, [PERSON_SCHEMA]), {}) }, extra=vol.ALLOW_EXTRA) +_UNDEF = object() -async def async_setup(hass, config): + +class PersonManager: + """Manage person data.""" + + def __init__(self, hass: HomeAssistantType, component: EntityComponent, + config_persons): + """Initialize person storage.""" + self.hass = hass + self.component = component + self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self.storage_data = None + + config_data = self.config_data = OrderedDict() + for conf in config_persons: + person_id = conf[CONF_ID] + + if person_id in config_data: + _LOGGER.error("Found config user with duplicate ID: %s", + person_id) + continue + + config_data[person_id] = conf + + @property + def storage_persons(self): + """Iterate over persons stored in storage.""" + return list(self.storage_data.values()) + + @property + def config_persons(self): + """Iterate over persons stored in config.""" + return list(self.config_data.values()) + + async def async_initialize(self): + """Get the person data.""" + raw_storage = await self.store.async_load() + + if raw_storage is None: + raw_storage = { + 'persons': [] + } + + storage_data = self.storage_data = OrderedDict() + + for person in raw_storage['persons']: + storage_data[person[CONF_ID]] = person + + entities = [] + + for person_conf in self.config_data.values(): + person_id = person_conf[CONF_ID] + user_id = person_conf.get(CONF_USER_ID) + + if (user_id is not None + and await self.hass.auth.async_get_user(user_id) is None): + _LOGGER.error( + "Invalid user_id detected for person %s", person_id) + continue + + entities.append(Person(person_conf, False)) + + for person_conf in storage_data.values(): + if person_conf[CONF_ID] in self.config_data: + _LOGGER.error( + "Skipping adding person from storage with same ID as" + " configuration.yaml entry: %s", person_id) + continue + + entities.append(Person(person_conf, True)) + + if entities: + await self.component.async_add_entities(entities) + + async def async_create_person(self, *, name, device_trackers=None, + user_id=None): + """Create a new person.""" + person = { + CONF_ID: uuid.uuid4().hex, + CONF_NAME: name, + CONF_USER_ID: user_id, + CONF_DEVICE_TRACKERS: device_trackers, + } + self.storage_data[person[CONF_ID]] = person + self._async_schedule_save() + await self.component.async_add_entities([Person(person, True)]) + return person + + async def async_update_person(self, person_id, *, name=_UNDEF, + device_trackers=_UNDEF, user_id=_UNDEF): + """Update person.""" + if person_id not in self.storage_data: + raise ValueError("Invalid person specified.") + + changes = { + key: value for key, value in ( + ('name', name), + ('device_trackers', device_trackers), + ('user_id', user_id) + ) if value is not _UNDEF + } + + self.storage_data[person_id].update(changes) + self._async_schedule_save() + + for entity in self.component.entities: + if entity.unique_id == person_id: + entity.person_updated() + break + + return self.storage_data[person_id] + + async def async_delete_person(self, person_id): + """Delete person.""" + if person_id not in self.storage_data: + raise ValueError("Invalid person specified.") + + self.storage_data.pop(person_id) + self._async_schedule_save() + ent_reg = await self.hass.helpers.entity_registry.async_get_registry() + + for entity in self.component.entities: + if entity.unique_id == person_id: + await entity.async_remove() + ent_reg.async_remove(entity.entity_id) + break + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the area registry.""" + self.store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict: + """Return data of area registry to store in a file.""" + return { + 'persons': list(self.storage_data.values()) + } + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): """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)) + conf_persons = config.get(DOMAIN, []) + manager = hass.data[DOMAIN] = PersonManager(hass, component, conf_persons) + await manager.async_initialize() - if not entities: - _LOGGER.error("No persons could be set up") - return False - - await component.async_add_entities(entities) + websocket_api.async_register_command(hass, ws_list_person) + websocket_api.async_register_command(hass, ws_create_person) + websocket_api.async_register_command(hass, ws_update_person) + websocket_api.async_register_command(hass, ws_delete_person) return True @@ -65,21 +207,20 @@ async def async_setup(hass, config): class Person(RestoreEntity): """Represent a tracked person.""" - def __init__(self, config, user_id): + def __init__(self, config, editable): """Set up person.""" - self._id = config[CONF_ID] + self._config = config + self._editable = editable 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 + self._unsub_track_device = None @property def name(self): """Return the name of the entity.""" - return self._name + return self._config[CONF_NAME] @property def should_poll(self): @@ -97,22 +238,25 @@ class Person(RestoreEntity): @property def state_attributes(self): """Return the state attributes of the person.""" - data = {} - data[ATTR_ID] = self._id + data = { + ATTR_EDITABLE: self._editable, + ATTR_ID: self.unique_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 + user_id = self._config.get(CONF_USER_ID) + if user_id is not None: + data[ATTR_USER_ID] = user_id return data @property def unique_id(self): """Return a unique ID for the person.""" - return self._id + return self._config[CONF_ID] async def async_added_to_hass(self): """Register device trackers.""" @@ -121,25 +265,137 @@ class Person(RestoreEntity): 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() + def person_start_hass(now): + self.person_updated() - _LOGGER.debug( - "Subscribe to device trackers for %s", self.entity_id) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, + person_start_hass) - for tracker in self._trackers: - async_track_state_change( - self.hass, tracker, async_handle_tracker_update) + @callback + def person_updated(self): + """Handle when the config is updated.""" + if self._unsub_track_device is not None: + self._unsub_track_device() + self._unsub_track_device = None + trackers = self._config.get(CONF_DEVICE_TRACKERS) + + if trackers: + def sort_key(state): + if state: + return state.last_updated + return dt_util.utc_from_timestamp(0) + + latest = max( + [self.hass.states.get(entity_id) for entity_id in trackers], + key=sort_key + ) + + @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) + + self._unsub_track_device = async_track_state_change( + self.hass, trackers, async_handle_tracker_update) + + else: + latest = None + + if latest: + self._parse_source_state(latest) + else: + self._state = None + self._source = None + self._latitude = None + self._longitude = None + + self.async_schedule_update_ha_state() + + @callback def _parse_source_state(self, state): - """Parse source state and set person attributes.""" + """Parse source state and set person attributes. + + This is a device tracker state or the restored person state. + """ self._state = state.state self._source = state.entity_id self._latitude = state.attributes.get(ATTR_LATITUDE) self._longitude = state.attributes.get(ATTR_LONGITUDE) + + +@websocket_api.websocket_command({ + vol.Required('type'): 'person/list', +}) +def ws_list_person(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, msg): + """List persons.""" + manager = hass.data[DOMAIN] # type: PersonManager + connection.send_result(msg['id'], { + 'storage': manager.storage_persons, + 'config': manager.config_persons, + }) + + +@websocket_api.websocket_command({ + vol.Required('type'): 'person/create', + vol.Required('name'): str, + vol.Optional('user_id'): vol.Any(str, None), + vol.Optional('device_trackers', default=[]): vol.All( + cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)), +}) +@websocket_api.require_admin +@websocket_api.async_response +async def ws_create_person(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, msg): + """Create a person.""" + manager = hass.data[DOMAIN] # type: PersonManager + person = await manager.async_create_person( + name=msg['name'], + user_id=msg.get('user_id'), + device_trackers=msg['device_trackers'] + ) + connection.send_result(msg['id'], person) + + +@websocket_api.websocket_command({ + vol.Required('type'): 'person/update', + vol.Required('person_id'): str, + vol.Optional('name'): str, + vol.Optional('user_id'): vol.Any(str, None), + vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All( + cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)), +}) +@websocket_api.require_admin +@websocket_api.async_response +async def ws_update_person(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, msg): + """Update a person.""" + manager = hass.data[DOMAIN] # type: PersonManager + changes = {} + for key in ('name', 'user_id', 'device_trackers'): + if key in msg: + changes[key] = msg[key] + + person = await manager.async_update_person(msg['person_id'], **changes) + connection.send_result(msg['id'], person) + + +@websocket_api.websocket_command({ + vol.Required('type'): 'person/delete', + vol.Required('person_id'): str, +}) +@websocket_api.require_admin +@websocket_api.async_response +async def ws_delete_person(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, + msg): + """Delete a person.""" + manager = hass.data[DOMAIN] # type: PersonManager + await manager.async_delete_person(msg['person_id']) + connection.send_result(msg['id']) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 60e2caa54ac..041aad3969e 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -31,6 +31,16 @@ class ActiveConnection: return Context() return Context(user_id=user.id) + @callback + def send_result(self, msg_id, result=None): + """Send a result message.""" + self.send_message(messages.result_message(msg_id, result)) + + @callback + def send_error(self, msg_id, code, message): + """Send a error message.""" + self.send_message(messages.error_message(msg_id, code, message)) + @callback def async_handle(self, msg): """Handle a single incoming message.""" diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 2d4ad68dbbe..c13ebe7cfab 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -319,7 +319,7 @@ class Entity: @callback def async_schedule_update_ha_state(self, force_refresh=False): """Schedule an update ha state change task.""" - self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + self.hass.async_create_task(self.async_update_ha_state(force_refresh)) async def async_device_update(self, warning=True): """Process 'update' or 'async_update' from entity. diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 4b10846ee3c..9b76135f743 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,16 +1,41 @@ """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) + ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN, + EVENT_HOMEASSISTANT_START) from homeassistant.core import CoreState, State from homeassistant.setup import async_setup_component +import pytest + from tests.common import mock_component, mock_restore_cache DEVICE_TRACKER = 'device_tracker.test_tracker' DEVICE_TRACKER_2 = 'device_tracker.test_tracker_2' +@pytest.fixture +def storage_setup(hass, hass_storage, hass_admin_user): + """Storage setup.""" + hass_storage[DOMAIN] = { + 'key': DOMAIN, + 'version': 1, + 'data': { + 'persons': [ + { + 'id': '1234', + 'name': 'tracked person', + 'user_id': hass_admin_user.id, + 'device_trackers': DEVICE_TRACKER + } + ] + } + } + assert hass.loop.run_until_complete( + async_setup_component(hass, DOMAIN, {}) + ) + + async def test_minimal_setup(hass): """Test minimal config with only name.""" config = {DOMAIN: {'id': '1234', 'name': 'test person'}} @@ -36,9 +61,9 @@ async def test_setup_no_name(hass): assert not await async_setup_component(hass, DOMAIN, config) -async def test_setup_user_id(hass, hass_owner_user): +async def test_setup_user_id(hass, hass_admin_user): """Test config with user id.""" - user_id = hass_owner_user.id + user_id = hass_admin_user.id config = { DOMAIN: {'id': '1234', 'name': 'test person', 'user_id': user_id}} assert await async_setup_component(hass, DOMAIN, config) @@ -52,17 +77,9 @@ async def test_setup_user_id(hass, hass_owner_user): 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): +async def test_valid_invalid_user_ids(hass, hass_admin_user): """Test a person with valid user id and a person with invalid user id .""" - user_id = hass_owner_user.id + user_id = hass_admin_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'}]} @@ -79,9 +96,9 @@ async def test_valid_invalid_user_ids(hass, hass_owner_user): assert state is None -async def test_setup_tracker(hass, hass_owner_user): +async def test_setup_tracker(hass, hass_admin_user): """Test set up person with one device tracker.""" - user_id = hass_owner_user.id + user_id = hass_admin_user.id config = {DOMAIN: { 'id': '1234', 'name': 'tracked person', 'user_id': user_id, 'device_trackers': DEVICE_TRACKER}} @@ -98,6 +115,12 @@ async def test_setup_tracker(hass, hass_owner_user): hass.states.async_set(DEVICE_TRACKER, 'home') await hass.async_block_till_done() + state = hass.states.get('person.tracked_person') + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + state = hass.states.get('person.tracked_person') assert state.state == 'home' assert state.attributes.get(ATTR_ID) == '1234' @@ -120,9 +143,9 @@ async def test_setup_tracker(hass, hass_owner_user): assert state.attributes.get(ATTR_USER_ID) == user_id -async def test_setup_two_trackers(hass, hass_owner_user): +async def test_setup_two_trackers(hass, hass_admin_user): """Test set up person with two device trackers.""" - user_id = hass_owner_user.id + user_id = hass_admin_user.id config = {DOMAIN: { 'id': '1234', 'name': 'tracked person', 'user_id': user_id, 'device_trackers': [DEVICE_TRACKER, DEVICE_TRACKER_2]}} @@ -136,6 +159,8 @@ async def test_setup_two_trackers(hass, hass_owner_user): assert state.attributes.get(ATTR_SOURCE) is None assert state.attributes.get(ATTR_USER_ID) == user_id + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() hass.states.async_set(DEVICE_TRACKER, 'home') await hass.async_block_till_done() @@ -161,9 +186,9 @@ async def test_setup_two_trackers(hass, hass_owner_user): assert state.attributes.get(ATTR_USER_ID) == user_id -async def test_restore_home_state(hass, hass_owner_user): +async def test_restore_home_state(hass, hass_admin_user): """Test that the state is restored for a person on startup.""" - user_id = hass_owner_user.id + user_id = hass_admin_user.id attrs = { ATTR_ID: '1234', ATTR_LATITUDE: 10.12346, ATTR_LONGITUDE: 11.12346, ATTR_SOURCE: DEVICE_TRACKER, ATTR_USER_ID: user_id} @@ -184,3 +209,203 @@ async def test_restore_home_state(hass, hass_owner_user): # 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 + + +async def test_duplicate_ids(hass, hass_admin_user): + """Test we don't allow duplicate IDs.""" + config = {DOMAIN: [ + {'id': '1234', 'name': 'test user 1'}, + {'id': '1234', 'name': 'test user 2'}]} + assert await async_setup_component(hass, DOMAIN, config) + + assert len(hass.states.async_entity_ids('person')) == 1 + assert hass.states.get('person.test_user_1') is not None + assert hass.states.get('person.test_user_2') is None + + +async def test_load_person_storage(hass, hass_admin_user, storage_setup): + """Test set up person from storage.""" + 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) == hass_admin_user.id + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + 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) == hass_admin_user.id + + +async def test_ws_list(hass, hass_ws_client, storage_setup): + """Test listing via WS.""" + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/list', + }) + resp = await client.receive_json() + assert resp['success'] + assert resp['result']['storage'] == manager.storage_persons + assert len(resp['result']['storage']) == 1 + assert len(resp['result']['config']) == 0 + + +async def test_ws_create(hass, hass_ws_client, storage_setup, + hass_read_only_user): + """Test creating via WS.""" + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/create', + 'name': 'Hello', + 'device_trackers': [DEVICE_TRACKER], + 'user_id': hass_read_only_user.id, + }) + resp = await client.receive_json() + + persons = manager.storage_persons + assert len(persons) == 2 + + assert resp['success'] + assert resp['result'] == persons[1] + + +async def test_ws_create_requires_admin(hass, hass_ws_client, storage_setup, + hass_admin_user, hass_read_only_user): + """Test creating via WS requires admin.""" + hass_admin_user.groups = [] + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/create', + 'name': 'Hello', + 'device_trackers': [DEVICE_TRACKER], + 'user_id': hass_read_only_user.id, + }) + resp = await client.receive_json() + + persons = manager.storage_persons + assert len(persons) == 1 + + assert not resp['success'] + + +async def test_ws_update(hass, hass_ws_client, storage_setup): + """Test updating via WS.""" + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + persons = manager.storage_persons + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/update', + 'person_id': persons[0]['id'], + 'name': 'Updated Name', + 'device_trackers': [DEVICE_TRACKER_2], + 'user_id': None, + }) + resp = await client.receive_json() + + persons = manager.storage_persons + assert len(persons) == 1 + + assert resp['success'] + assert resp['result'] == persons[0] + assert persons[0]['name'] == 'Updated Name' + assert persons[0]['name'] == 'Updated Name' + assert persons[0]['device_trackers'] == [DEVICE_TRACKER_2] + assert persons[0]['user_id'] is None + + state = hass.states.get('person.tracked_person') + assert state.name == 'Updated Name' + + +async def test_ws_update_require_admin(hass, hass_ws_client, storage_setup, + hass_admin_user): + """Test updating via WS requires admin.""" + hass_admin_user.groups = [] + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + original = dict(manager.storage_persons[0]) + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/update', + 'person_id': original['id'], + 'name': 'Updated Name', + 'device_trackers': [DEVICE_TRACKER_2], + 'user_id': None, + }) + resp = await client.receive_json() + assert not resp['success'] + + not_updated = dict(manager.storage_persons[0]) + assert original == not_updated + + +async def test_ws_delete(hass, hass_ws_client, storage_setup): + """Test deleting via WS.""" + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + persons = manager.storage_persons + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/delete', + 'person_id': persons[0]['id'], + }) + resp = await client.receive_json() + + persons = manager.storage_persons + assert len(persons) == 0 + + assert resp['success'] + assert len(hass.states.async_entity_ids('person')) == 0 + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert not ent_reg.async_is_registered('person.tracked_person') + + +async def test_ws_delete_require_admin(hass, hass_ws_client, storage_setup, + hass_admin_user): + """Test deleting via WS requires admin.""" + hass_admin_user.groups = [] + manager = hass.data[DOMAIN] + + client = await hass_ws_client(hass) + + resp = await client.send_json({ + 'id': 6, + 'type': 'person/delete', + 'person_id': manager.storage_persons[0]['id'], + 'name': 'Updated Name', + 'device_trackers': [DEVICE_TRACKER_2], + 'user_id': None, + }) + resp = await client.receive_json() + assert not resp['success'] + + persons = manager.storage_persons + assert len(persons) == 1