"""The tests for the person component.""" from datetime import timedelta from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import person from homeassistant.components.device_tracker import ( ATTR_IN_ZONES, ATTR_SOURCE_TYPE, ATTR_TRACKING_TYPE, SourceType, TrackingType, ) from homeassistant.components.person import ( ATTR_DEVICE_TRACKERS, ATTR_SOURCE, ATTR_USER_ID, DOMAIN, ) from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache from tests.typing import WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: """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 == { ATTR_DEVICE_TRACKERS: [], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "test person", ATTR_ID: "1234", ATTR_IN_ZONES: [], } async def test_setup_no_id(hass: HomeAssistant) -> None: """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: HomeAssistant) -> None: """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: HomeAssistant, hass_admin_user: MockUser) -> None: """Test config with 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) state = hass.states.get("person.test_person") assert state.state == STATE_UNKNOWN assert state.attributes == { ATTR_DEVICE_TRACKERS: [], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "test person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } async def test_valid_invalid_user_ids( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test a person with valid user id and a person with invalid 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"}, ] } assert await async_setup_component(hass, DOMAIN, config) state = hass.states.get("person.test_valid_user") assert state.state == STATE_UNKNOWN assert state.attributes == { ATTR_DEVICE_TRACKERS: [], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "test valid user", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } state = hass.states.get("person.test_bad_user") assert state is None async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test set up person with one device tracker.""" hass.set_state(CoreState.not_running) user_id = hass_admin_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) expected_attributes = { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes # Test home without coordinates 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() # A legacy tracker reporting home (no coordinates) is placed at the home zone. state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == expected_attributes | { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER, } # Test home with coordinates hass.states.async_set( DEVICE_TRACKER, "home", {ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_GPS_ACCURACY: 10}, ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == expected_attributes | { ATTR_GPS_ACCURACY: 10, ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_SOURCE: DEVICE_TRACKER, } # Test not_home without coordinates hass.states.async_set( DEVICE_TRACKER, "not_home", ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER} # Test not_home with coordinates hass.states.async_set( DEVICE_TRACKER, "not_home", {ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_GPS_ACCURACY: 10}, ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | { ATTR_GPS_ACCURACY: 10, ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_SOURCE: DEVICE_TRACKER, } async def test_setup_two_trackers( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers.""" hass.set_state(CoreState.not_running) user_id = hass_admin_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) expected_attributes = { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() hass.states.async_set( DEVICE_TRACKER, "home", { ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_IN_ZONES: ["zone.home"], }, ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == expected_attributes | { ATTR_IN_ZONES: ["zone.home"], ATTR_SOURCE: DEVICE_TRACKER, } hass.states.async_set( DEVICE_TRACKER_2, "not_home", { ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_GPS_ACCURACY: 12, ATTR_IN_ZONES: ["zone.work"], ATTR_SOURCE_TYPE: SourceType.GPS, }, ) await hass.async_block_till_done() hass.states.async_set( DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | { ATTR_GPS_ACCURACY: 12, ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_IN_ZONES: ["zone.work"], ATTR_SOURCE: DEVICE_TRACKER_2, } hass.states.async_set(DEVICE_TRACKER_2, "zone1", {ATTR_SOURCE_TYPE: SourceType.GPS}) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "zone1" assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2} hass.states.async_set(DEVICE_TRACKER, "home", {ATTR_SOURCE_TYPE: SourceType.ROUTER}) await hass.async_block_till_done() hass.states.async_set(DEVICE_TRACKER_2, "zone2", {ATTR_SOURCE_TYPE: SourceType.GPS}) await hass.async_block_till_done() # Legacy router reporting home (no in_zones) is placed at the home zone. state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == expected_attributes | { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER, } async def test_setup_router_ble_trackers( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test router and BLE trackers.""" # BLE trackers are considered stationary trackers; however unlike # a router based tracker whose states are home and not_home, a BLE # tracker may have the value of any zone that the beacon is # configured for. hass.set_state(CoreState.not_running) user_id = hass_admin_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) expected_attributes = { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() hass.states.async_set( DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER} # Set the BLE tracker to the "office" zone. hass.states.async_set( DEVICE_TRACKER_2, "office", { ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_GPS_ACCURACY: 12, ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, }, ) await hass.async_block_till_done() # The person should be in the office. state = hass.states.get("person.tracked_person") assert state.state == "office" assert state.attributes == expected_attributes | { ATTR_GPS_ACCURACY: 12, ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE: DEVICE_TRACKER_2, } # Representative device tracker states for the priority buckets used by # `Person._update_state`, in priority order: # 1. a scanner reporting a non-empty in_zones, i.e. connected in a known # zone (highest priority) # 2. a tracker reporting "home" # 3. any GPS tracker, regardless of its state # 4. everything else, e.g. a tracker reporting "not_home" (lowest priority) # Each value is a (state, attributes) tuple passed to `hass.states.async_set`. _ROUTER_HOME: tuple[str, dict[str, Any]] = ( "home", { ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: ["zone.home"], }, ) _ROUTER_NOT_HOME: tuple[str, dict[str, Any]] = ( "not_home", { ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: [], }, ) # A scanner tracker associated with a non-home zone reports the zone's name as # its state and lists the zone in `in_zones` (see device_tracker PR #172157). _SCANNER_OFFICE: tuple[str, dict[str, Any]] = ( "office", { ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: ["zone.office"], }, ) _GPS_NOT_HOME: tuple[str, dict[str, Any]] = ( "not_home", { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_TRACKING_TYPE: TrackingType.POSITION, ATTR_LATITUDE: 1.0, ATTR_LONGITUDE: 2.0, ATTR_GPS_ACCURACY: 5, ATTR_IN_ZONES: [], }, ) _GPS_WORK: tuple[str, dict[str, Any]] = ( "work", { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_TRACKING_TYPE: TrackingType.POSITION, ATTR_LATITUDE: 3.0, ATTR_LONGITUDE: 4.0, ATTR_GPS_ACCURACY: 7, ATTR_IN_ZONES: ["zone.work"], }, ) # Legacy trackers come from integrations that predate `in_zones`, so they report # only a state and a source type (no `in_zones`). _LEGACY_HOME: tuple[str, dict[str, Any]] = ( "home", {ATTR_SOURCE_TYPE: SourceType.ROUTER}, ) _LEGACY_NOT_HOME: tuple[str, dict[str, Any]] = ( "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER}, ) # A legacy tracker in a non-home zone reports the zone name, still no `in_zones`. _LEGACY_OFFICE: tuple[str, dict[str, Any]] = ( "office", {ATTR_SOURCE_TYPE: SourceType.ROUTER}, ) # Legacy GPS trackers report coordinates but no `in_zones`. _LEGACY_GPS_NOT_HOME: tuple[str, dict[str, Any]] = ( "not_home", { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_LATITUDE: 5.0, ATTR_LONGITUDE: 6.0, ATTR_GPS_ACCURACY: 8, }, ) _LEGACY_GPS_WORK: tuple[str, dict[str, Any]] = ( "work", { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_LATITUDE: 7.0, ATTR_LONGITUDE: 8.0, ATTR_GPS_ACCURACY: 9, }, ) async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) -> None: """Set up a person tracked by two device trackers, with hass running.""" hass.set_state(CoreState.not_running) 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) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() @pytest.mark.parametrize( ("high_priority", "low_priority", "expected_state", "expected_extra"), [ # A scanner reporting a zone outranks a GPS tracker. It has no # coordinates. pytest.param( _ROUTER_HOME, _GPS_NOT_HOME, "home", { ATTR_IN_ZONES: ["zone.home"], ATTR_SOURCE: DEVICE_TRACKER, }, id="scanner_beats_gps", ), # A legacy "home" tracker (no in_zones) likewise outranks GPS; it is # placed at the home zone. pytest.param( _LEGACY_HOME, _GPS_NOT_HOME, "home", { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER, }, id="legacy_home_beats_gps", ), # A GPS tracker outranks a "not_home" tracker. pytest.param( _GPS_WORK, _ROUTER_NOT_HOME, "work", { ATTR_GPS_ACCURACY: 7, ATTR_LATITUDE: 3.0, ATTR_LONGITUDE: 4.0, ATTR_IN_ZONES: ["zone.work"], ATTR_SOURCE: DEVICE_TRACKER, }, id="gps_beats_not_home", ), ], ) async def test_state_priority_overrides_recency( hass: HomeAssistant, hass_admin_user: MockUser, freezer: FrozenDateTimeFactory, high_priority: tuple[str, dict[str, Any]], low_priority: tuple[str, dict[str, Any]], expected_state: str, expected_extra: dict[str, Any], ) -> None: """Test the higher-priority bucket wins even when its state is stale. There is no time-based expiry: a long-stale state from a higher-priority bucket still wins over a fresh state from a lower-priority bucket. """ await _async_setup_person_two_trackers(hass, hass_admin_user.id) # The higher-priority tracker reports first and then goes stale. hass.states.async_set(DEVICE_TRACKER, high_priority[0], high_priority[1]) await hass.async_block_till_done() freezer.tick(timedelta(hours=2)) # The lower-priority tracker reports a much more recent update. hass.states.async_set(DEVICE_TRACKER_2, low_priority[0], low_priority[1]) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == expected_state assert ( state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: hass_admin_user.id, } | expected_extra ) @pytest.mark.parametrize( ("older", "newer", "expected_state", "expected_extra"), [ # GPS bucket: the most recent GPS state wins. pytest.param( _GPS_WORK, _GPS_NOT_HOME, "not_home", { ATTR_GPS_ACCURACY: 5, ATTR_LATITUDE: 1.0, ATTR_LONGITUDE: 2.0, ATTR_SOURCE: DEVICE_TRACKER_2, }, id="gps_newer_not_home", ), pytest.param( _GPS_NOT_HOME, _GPS_WORK, "work", { ATTR_GPS_ACCURACY: 7, ATTR_LATITUDE: 3.0, ATTR_LONGITUDE: 4.0, ATTR_IN_ZONES: ["zone.work"], ATTR_SOURCE: DEVICE_TRACKER_2, }, id="gps_newer_work", ), # Highest-priority bucket: the most recent scanner in a zone wins # (here a fresh "office" scanner over a stale "home" one). pytest.param( _ROUTER_HOME, _SCANNER_OFFICE, "office", {ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE: DEVICE_TRACKER_2}, id="scanner_newer_office", ), # Lowest-priority bucket: the most recent "not_home" tracker wins. pytest.param( _ROUTER_NOT_HOME, _LEGACY_NOT_HOME, "not_home", {ATTR_SOURCE: DEVICE_TRACKER_2}, id="not_home_newer", ), # A pair of legacy "home" trackers (no in_zones) likewise picks the # most recent; it is placed at the home zone. pytest.param( _LEGACY_HOME, _LEGACY_HOME, "home", { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER_2, }, id="legacy_home_newer", ), ], ) async def test_most_recent_state_in_bucket_wins( hass: HomeAssistant, hass_admin_user: MockUser, freezer: FrozenDateTimeFactory, older: tuple[str, dict[str, Any]], newer: tuple[str, dict[str, Any]], expected_state: str, expected_extra: dict[str, Any], ) -> None: """Test that within a bucket the most recently updated state is picked.""" await _async_setup_person_two_trackers(hass, hass_admin_user.id) hass.states.async_set(DEVICE_TRACKER, older[0], older[1]) await hass.async_block_till_done() freezer.tick(timedelta(minutes=5)) hass.states.async_set(DEVICE_TRACKER_2, newer[0], newer[1]) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == expected_state # The newer tracker is the source. assert ( state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: hass_admin_user.id, } | expected_extra ) async def test_scanner_associated_with_other_zone( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test a person tracked by a scanner associated with a non-home zone. A connected scanner associated with a non-home zone reports the zone name and lists the zone in `in_zones`. Being a non-GPS tracker, it provides no coordinates of its own. """ hass.set_state(CoreState.not_running) user_id = hass_admin_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) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() hass.states.async_set(DEVICE_TRACKER, _SCANNER_OFFICE[0], _SCANNER_OFFICE[1]) await hass.async_block_till_done() # No coordinates: a scanner tracker provides none of its own. state = hass.states.get("person.tracked_person") assert state.state == "office" assert state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE: DEVICE_TRACKER, ATTR_USER_ID: user_id, } @pytest.mark.parametrize( ("tracker", "expected_state", "expected_extra"), [ # A legacy "home" tracker has no coordinates of its own, so it is # placed at the home zone. pytest.param( _LEGACY_HOME, "home", { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER, }, id="home", ), # A legacy "not_home" tracker contributes no coordinates and no zones. pytest.param( _LEGACY_NOT_HOME, "not_home", {ATTR_SOURCE: DEVICE_TRACKER}, id="not_home", ), # A legacy tracker in a non-home zone gets no coordinate fallback. pytest.param( _LEGACY_OFFICE, "office", {ATTR_SOURCE: DEVICE_TRACKER}, id="office", ), # Legacy GPS trackers contribute their own coordinates but no zones. pytest.param( _LEGACY_GPS_NOT_HOME, "not_home", { ATTR_GPS_ACCURACY: 8, ATTR_LATITUDE: 5.0, ATTR_LONGITUDE: 6.0, ATTR_SOURCE: DEVICE_TRACKER, }, id="gps_not_home", ), pytest.param( _LEGACY_GPS_WORK, "work", { ATTR_GPS_ACCURACY: 9, ATTR_LATITUDE: 7.0, ATTR_LONGITUDE: 8.0, ATTR_SOURCE: DEVICE_TRACKER, }, id="gps_work", ), ], ) async def test_legacy_device_tracker( hass: HomeAssistant, hass_admin_user: MockUser, tracker: tuple[str, dict[str, Any]], expected_state: str, expected_extra: dict[str, Any], ) -> None: """Test a legacy tracker that reports a state but no in_zones.""" hass.set_state(CoreState.not_running) user_id = hass_admin_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) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() hass.states.async_set(DEVICE_TRACKER, tracker[0], tracker[1]) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == expected_state assert ( state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } | expected_extra ) @pytest.mark.parametrize( ("competitor"), [ # A fresh GPS tracker reporting coordinates... pytest.param(_GPS_WORK, id="vs_gps"), # ...and a fresh legacy "home" tracker both lose to the scanner that # reports being in a zone. pytest.param(_LEGACY_HOME, id="vs_legacy_home"), ], ) @pytest.mark.parametrize( ("scanner", "expected_state", "expected_in_zones"), [ pytest.param(_ROUTER_HOME, "home", ["zone.home"], id="home"), pytest.param(_SCANNER_OFFICE, "office", ["zone.office"], id="office"), ], ) async def test_scanner_in_zone_has_highest_priority( hass: HomeAssistant, hass_admin_user: MockUser, freezer: FrozenDateTimeFactory, scanner: tuple[str, dict[str, Any]], expected_state: str, expected_in_zones: list[str], competitor: tuple[str, dict[str, Any]], ) -> None: """Test a scanner in a zone wins, even when stale. A scanner reporting a non-empty `in_zones` is the most reliable presence signal and takes precedence over a fresher GPS or legacy "home" tracker. It contributes no coordinates of its own. """ await _async_setup_person_two_trackers(hass, hass_admin_user.id) # The scanner reports first and then goes stale. hass.states.async_set(DEVICE_TRACKER, scanner[0], scanner[1]) await hass.async_block_till_done() freezer.tick(timedelta(hours=2)) # A competing tracker reports a much more recent update. hass.states.async_set(DEVICE_TRACKER_2, competitor[0], competitor[1]) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == expected_state assert state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: expected_in_zones, ATTR_SOURCE: DEVICE_TRACKER, ATTR_USER_ID: hass_admin_user.id, } async def test_scanner_without_in_zones_not_prioritized( hass: HomeAssistant, hass_admin_user: MockUser, freezer: FrozenDateTimeFactory, ) -> None: """Test a disconnected scanner does not get highest priority. With an empty `in_zones` it falls into the lowest-priority bucket, so a GPS tracker wins. """ await _async_setup_person_two_trackers(hass, hass_admin_user.id) hass.states.async_set(DEVICE_TRACKER, _ROUTER_NOT_HOME[0], _ROUTER_NOT_HOME[1]) await hass.async_block_till_done() freezer.tick(timedelta(minutes=5)) hass.states.async_set(DEVICE_TRACKER_2, _GPS_WORK[0], _GPS_WORK[1]) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "work" assert state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_GPS_ACCURACY: 7, ATTR_ID: "1234", ATTR_IN_ZONES: ["zone.work"], ATTR_LATITUDE: 3.0, ATTR_LONGITUDE: 4.0, ATTR_SOURCE: DEVICE_TRACKER_2, ATTR_USER_ID: hass_admin_user.id, } async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers, one unavailable.""" hass.set_state(CoreState.not_running) user_id = hass_admin_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) expected_attributes = { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2], ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes 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() hass.states.async_set(DEVICE_TRACKER, "unavailable") await hass.async_block_till_done() # Unknown, as only 1 device tracker has a state, but we ignore that one state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes hass.states.async_set(DEVICE_TRACKER_2, "not_home") await hass.async_block_till_done() # Take state of tracker 2 state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2} # state 1 is newer but ignored, keep tracker 2 state hass.states.async_set(DEVICE_TRACKER, "unknown") await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "not_home" assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2} async def test_restore_home_state( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test that the state is restored for a person on startup.""" 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, } state = State("person.tracked_person", "home", attrs) mock_restore_cache(hass, (state,)) hass.set_state(CoreState.not_running) mock_component(hass, "recorder") config = { DOMAIN: { "id": "1234", "name": "tracked person", "user_id": user_id, "device_trackers": DEVICE_TRACKER, "picture": "/bla", } } assert await async_setup_component(hass, DOMAIN, config) # When restoring state the entity_id of the person will be used as source. state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], ATTR_EDITABLE: False, ATTR_ENTITY_PICTURE: "/bla", ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_LATITUDE: 10.12346, ATTR_LONGITUDE: 11.12346, ATTR_SOURCE: "person.tracked_person", ATTR_USER_ID: user_id, } async def test_duplicate_ids(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """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(DOMAIN)) == 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_create_person_during_run(hass: HomeAssistant) -> None: """Test that person is updated if created while hass is running.""" config = {DOMAIN: {}} assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(DEVICE_TRACKER, "home") await hass.async_block_till_done() await person.async_create_person( hass, "tracked person", device_trackers=[DEVICE_TRACKER] ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") assert state.state == "home" async def test_load_person_storage( hass: HomeAssistant, hass_admin_user: MockUser, storage_setup ) -> None: """Test set up person from storage.""" expected_attributes = { ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", ATTR_IN_ZONES: [], ATTR_USER_ID: hass_admin_user.id, } state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN assert state.attributes == expected_attributes 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() # A legacy tracker reporting home (no coordinates) is placed at the home zone. state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes == expected_attributes | { ATTR_LATITUDE: 32.87336, ATTR_LONGITUDE: -117.22743, ATTR_SOURCE: DEVICE_TRACKER, } async def test_load_person_storage_two_nonlinked( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading two users with both not having a user linked.""" hass_storage[DOMAIN] = { "key": DOMAIN, "version": 1, "data": { "persons": [ { "id": "1234", "name": "tracked person 1", "user_id": None, "device_trackers": [], }, { "id": "5678", "name": "tracked person 2", "user_id": None, "device_trackers": [], }, ] }, } await async_setup_component(hass, DOMAIN, {}) assert len(hass.states.async_entity_ids(DOMAIN)) == 2 assert hass.states.get("person.tracked_person_1") is not None assert hass.states.get("person.tracked_person_2") is not None async def test_ws_list( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup ) -> None: """Test listing via WS.""" manager = hass.data[DOMAIN][1] 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.async_items() assert len(resp["result"]["storage"]) == 1 assert len(resp["result"]["config"]) == 0 async def test_ws_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup, hass_read_only_user: MockUser, ) -> None: """Test creating via WS.""" manager = hass.data[DOMAIN][1] 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, "picture": "/bla", } ) resp = await client.receive_json() persons = manager.async_items() assert len(persons) == 2 assert resp["success"] assert resp["result"] == persons[1] async def test_ws_create_requires_admin( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup, hass_admin_user: MockUser, hass_read_only_user: MockUser, ) -> None: """Test creating via WS requires admin.""" hass_admin_user.groups = [] manager = hass.data[DOMAIN][1] 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.async_items() assert len(persons) == 1 assert not resp["success"] async def test_ws_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup ) -> None: """Test updating via WS.""" manager = hass.data[DOMAIN][1] client = await hass_ws_client(hass) persons = manager.async_items() resp = await client.send_json( { "id": 6, "type": "person/update", "person_id": persons[0]["id"], "user_id": persons[0]["user_id"], } ) resp = await client.receive_json() assert resp["success"] resp = await client.send_json( { "id": 7, "type": "person/update", "person_id": persons[0]["id"], "name": "Updated Name", "device_trackers": [DEVICE_TRACKER_2], "user_id": None, "picture": "/bla", } ) resp = await client.receive_json() persons = manager.async_items() 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 assert persons[0]["picture"] == "/bla" state = hass.states.get("person.tracked_person") assert state.name == "Updated Name" async def test_ws_update_require_admin( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup, hass_admin_user: MockUser, ) -> None: """Test updating via WS requires admin.""" hass_admin_user.groups = [] manager = hass.data[DOMAIN][1] client = await hass_ws_client(hass) original = dict(manager.async_items()[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.async_items()[0]) assert original == not_updated async def test_ws_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entity_registry: er.EntityRegistry, storage_setup, ) -> None: """Test deleting via WS.""" manager = hass.data[DOMAIN][1] client = await hass_ws_client(hass) persons = manager.async_items() resp = await client.send_json( {"id": 6, "type": "person/delete", "person_id": persons[0]["id"]} ) resp = await client.receive_json() persons = manager.async_items() assert len(persons) == 0 assert resp["success"] assert len(hass.states.async_entity_ids(DOMAIN)) == 0 assert not entity_registry.async_is_registered("person.tracked_person") async def test_ws_delete_require_admin( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup, hass_admin_user: MockUser, ) -> None: """Test deleting via WS requires admin.""" hass_admin_user.groups = [] manager = hass.data[DOMAIN][1] client = await hass_ws_client(hass) resp = await client.send_json( { "id": 6, "type": "person/delete", "person_id": manager.async_items()[0]["id"], "name": "Updated Name", "device_trackers": [DEVICE_TRACKER_2], "user_id": None, } ) resp = await client.receive_json() assert not resp["success"] persons = manager.async_items() assert len(persons) == 1 async def test_create_invalid_user_id(hass: HomeAssistant, storage_collection) -> None: """Test we do not allow invalid user ID during creation.""" with pytest.raises(ValueError): await storage_collection.async_create_item( {"name": "Hello", "user_id": "non-existing"} ) async def test_create_duplicate_user_id( hass: HomeAssistant, hass_admin_user: MockUser, storage_collection ) -> None: """Test we do not allow duplicate user ID during creation.""" await storage_collection.async_create_item( {"name": "Hello", "user_id": hass_admin_user.id} ) with pytest.raises(ValueError): await storage_collection.async_create_item( {"name": "Hello", "user_id": hass_admin_user.id} ) async def test_update_double_user_id( hass: HomeAssistant, hass_admin_user: MockUser, storage_collection ) -> None: """Test we do not allow double user ID during update.""" await storage_collection.async_create_item( {"name": "Hello", "user_id": hass_admin_user.id} ) person = await storage_collection.async_create_item({"name": "Hello"}) with pytest.raises(ValueError): await storage_collection.async_update_item( person["id"], {"user_id": hass_admin_user.id} ) async def test_update_invalid_user_id(hass: HomeAssistant, storage_collection) -> None: """Test updating to invalid user ID.""" person = await storage_collection.async_create_item({"name": "Hello"}) with pytest.raises(ValueError): await storage_collection.async_update_item( person["id"], {"user_id": "non-existing"} ) async def test_update_person_when_user_removed( hass: HomeAssistant, storage_setup, hass_read_only_user: MockUser ) -> None: """Update person when user is removed.""" storage_collection = hass.data[DOMAIN][1] person = await storage_collection.async_create_item( {"name": "Hello", "user_id": hass_read_only_user.id} ) await hass.auth.async_remove_user(hass_read_only_user) await hass.async_block_till_done() assert storage_collection.data[person["id"]]["user_id"] is None async def test_removing_device_tracker( hass: HomeAssistant, entity_registry: er.EntityRegistry, storage_setup ) -> None: """Test we automatically remove removed device trackers.""" storage_collection = hass.data[DOMAIN][1] entry = entity_registry.async_get_or_create( "device_tracker", "mobile_app", "bla", suggested_object_id="pixel" ) person = await storage_collection.async_create_item( {"name": "Hello", "device_trackers": [entry.entity_id]} ) entity_registry.async_remove(entry.entity_id) await hass.async_block_till_done() assert storage_collection.data[person["id"]]["device_trackers"] == [] async def test_add_user_device_tracker( hass: HomeAssistant, storage_setup, hass_read_only_user: MockUser ) -> None: """Test adding a device tracker to a person tied to a user.""" storage_collection = hass.data[DOMAIN][1] pers = await storage_collection.async_create_item( { "name": "Hello", "user_id": hass_read_only_user.id, "device_trackers": ["device_tracker.on_create"], } ) await person.async_add_user_device_tracker( hass, hass_read_only_user.id, "device_tracker.added" ) assert storage_collection.data[pers["id"]]["device_trackers"] == [ "device_tracker.on_create", "device_tracker.added", ] async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test reloading the YAML config.""" assert await async_setup_component( hass, DOMAIN, { DOMAIN: [ {"name": "Person 1", "id": "id-1"}, {"name": "Person 2", "id": "id-2"}, ] }, ) assert len(hass.states.async_entity_ids()) == 3 # zone.home, Person1, Person2 state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") state_3 = hass.states.get("person.person_3") assert state_1 is not None assert state_1.name == "Person 1" assert state_2 is not None assert state_2.name == "Person 2" assert state_3 is None with patch( "homeassistant.config.load_yaml_config_file", autospec=True, return_value={ DOMAIN: [ {"name": "Person 1-updated", "id": "id-1"}, {"name": "Person 3", "id": "id-3"}, ] }, ): await hass.services.async_call( DOMAIN, SERVICE_RELOAD, blocking=True, context=Context(user_id=hass_admin_user.id), ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 3 # zone.home, Person1, Person3 state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") state_3 = hass.states.get("person.person_3") assert state_1 is not None assert state_1.name == "Person 1-updated" assert state_2 is None assert state_3 is not None assert state_3.name == "Person 3" async def test_person_storage_fixing_device_trackers(storage_collection) -> None: """Test None device trackers become lists.""" with patch.object( storage_collection.store, "async_load", return_value={"items": [{"id": "bla", "name": "bla", "device_trackers": None}]}, ): await storage_collection.async_load() assert storage_collection.data["bla"]["device_trackers"] == [] async def test_persons_with_entity(hass: HomeAssistant) -> None: """Test finding persons with an entity.""" assert await async_setup_component( hass, DOMAIN, { "person": [ { "id": "abcd", "name": "Paulus", "device_trackers": [ "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ], }, { "id": "efgh", "name": "Anne Therese", "device_trackers": [ "device_tracker.at_pixel", ], }, ] }, ) assert person.persons_with_entity(hass, "device_tracker.paulus_iphone") == [ "person.paulus" ] async def test_entities_in_person(hass: HomeAssistant) -> None: """Test finding entities tracked by person.""" assert await async_setup_component( hass, DOMAIN, { "person": [ { "id": "abcd", "name": "Paulus", "device_trackers": [ "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ], } ] }, ) assert person.entities_in_person(hass, "person.paulus") == [ "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ]