From f5c66bb8ba0539ab573c94a6011ea119f20046bb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Sep 2026 15:53:18 +0200 Subject: [PATCH] Add device_trackers state attribute to zone entity states (#181001) --- homeassistant/components/zone/__init__.py | 47 +++- homeassistant/components/zone/const.py | 1 + .../snapshots/test_device_tracker.ambr | 4 + tests/components/person/test_recorder.py | 2 +- tests/components/zone/test_init.py | 235 +++++++++++------- 5 files changed, 187 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index f53a4226fa6a..78a220b06f05 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -439,6 +439,7 @@ class Zone(collection.CollectionEntity): self._attrs: dict | None = None self._remove_listener: Callable[[], None] | None = None self._persons_in_zone: set[str] = set() + self._device_trackers_in_zone: set[str] = set() self._set_attrs_from_config() def _set_attrs_from_config(self) -> None: @@ -484,29 +485,47 @@ class Zone(collection.CollectionEntity): self.async_write_ha_state() @callback - def _person_state_change_listener(self, evt: Event[EventStateChangedData]) -> None: - person_entity_id = evt.data["entity_id"] - persons_in_zone = self._persons_in_zone - cur_count = len(persons_in_zone) + def _update_tracked_in_zone( + self, tracked_in_zone: set[str], evt: Event[EventStateChangedData] + ) -> None: + entity_id = evt.data["entity_id"] + cur_count = len(tracked_in_zone) if self._state_is_in_zone(evt.data["new_state"]): - persons_in_zone.add(person_entity_id) - elif person_entity_id in persons_in_zone: - persons_in_zone.remove(person_entity_id) + tracked_in_zone.add(entity_id) + elif entity_id in tracked_in_zone: + tracked_in_zone.remove(entity_id) - if len(persons_in_zone) != cur_count: + if len(tracked_in_zone) != cur_count: self._generate_attrs() self.async_write_ha_state() + @callback + def _person_state_change_listener(self, evt: Event[EventStateChangedData]) -> None: + self._update_tracked_in_zone(self._persons_in_zone, evt) + + @callback + def _device_tracker_state_change_listener( + self, evt: Event[EventStateChangedData] + ) -> None: + self._update_tracked_in_zone(self._device_trackers_in_zone, evt) + @override async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - person_domain = "person" # avoid circular import + # Domains are hardcoded to avoid circular imports. + person_domain = "person" + device_tracker_domain = "device_tracker" self._persons_in_zone = { state.entity_id for state in self.hass.states.async_all(person_domain) if self._state_is_in_zone(state) } + self._device_trackers_in_zone = { + state.entity_id + for state in self.hass.states.async_all(device_tracker_domain) + if self._state_is_in_zone(state) + } self._generate_attrs() self.async_on_remove( @@ -516,6 +535,13 @@ class Zone(collection.CollectionEntity): self._person_state_change_listener, ).async_remove ) + self.async_on_remove( + event.async_track_state_change_filtered( + self.hass, + event.TrackStates(False, set(), {device_tracker_domain}), + self._device_tracker_state_change_listener, + ).async_remove + ) @callback def _generate_attrs(self) -> None: @@ -526,6 +552,9 @@ class Zone(collection.CollectionEntity): ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), + ZoneEntityStateAttribute.DEVICE_TRACKERS: sorted( + self._device_trackers_in_zone + ), ZoneEntityStateAttribute.EDITABLE: self.editable, } diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index 0725b2ce6fdf..232b7557d720 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -13,6 +13,7 @@ class ZoneEntityStateAttribute(StrEnum): RADIUS = "radius" PASSIVE = "passive" PERSONS = "persons" + DEVICE_TRACKERS = "device_trackers" EDITABLE = "editable" diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index b919d0f61957..339695428c05 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -38,6 +38,10 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : list([ + 'device_tracker.demo_scanner', + 'device_tracker.demo_tracker', + ]), : True, : 'test home', : 'mdi:home', diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py index fe7a3894a05a..cdec72530718 100644 --- a/tests/components/person/test_recorder.py +++ b/tests/components/person/test_recorder.py @@ -38,7 +38,7 @@ async def test_exclude_attributes( await async_wait_recording_done(hass) states = await hass.async_add_executor_job( - get_significant_states, hass, now, None, hass.states.async_entity_ids() + get_significant_states, hass, now, None, hass.states.async_entity_ids(DOMAIN) ) assert len(states) >= 1 for entity_states in states.values(): diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 7df44e4d60ef..e5b7ce468499 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant import setup from homeassistant.components import device_tracker, zone from homeassistant.components.zone import ATTR_RADIUS, DOMAIN +from homeassistant.components.zone.const import ZoneEntityStateAttribute from homeassistant.const import ( ATTR_EDITABLE, ATTR_FRIENDLY_NAME, @@ -15,7 +16,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, - ATTR_PERSONS, SERVICE_RELOAD, ) from homeassistant.core import Context, HomeAssistant @@ -841,8 +841,116 @@ async def test_async_get_enclosing_zones_skips_unavailable_other( assert zone.async_get_enclosing_zones(hass, "zone.inner") == [] -async def test_state(hass: HomeAssistant) -> None: - """Test the state of a zone.""" +@pytest.mark.parametrize( + ( + "tracked_domain", + "tracked_attr", + "untracked_attr", + "test_zone_states", + "home_states", + ), + [ + pytest.param( + "person", + ZoneEntityStateAttribute.PERSONS, + ZoneEntityStateAttribute.DEVICE_TRACKERS, + ["0", "1", "2", "1", "2", "1", "0"], + ["0", "0", "0", "1", "1", "0", "0"], + id="person", + ), + pytest.param( + "device_tracker", + ZoneEntityStateAttribute.DEVICE_TRACKERS, + ZoneEntityStateAttribute.PERSONS, + # Device trackers are not counted towards the zone state. + ["0", "0", "0", "0", "0", "0", "0"], + ["0", "0", "0", "0", "0", "0", "0"], + id="device_tracker", + ), + ], +) +async def test_state( + hass: HomeAssistant, + tracked_domain: str, + tracked_attr: ZoneEntityStateAttribute, + untracked_attr: ZoneEntityStateAttribute, + test_zone_states: list[str], + home_states: list[str], +) -> None: + """Test the state and the persons / device_trackers attributes of a zone.""" + one = f"{tracked_domain}.one" + two = f"{tracked_domain}.two" + + info = { + "name": "Test Zone", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + "passive": False, + } + assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) + assert len(hass.states.async_entity_ids(DOMAIN)) == 2 + + def assert_zone( + entity_id: str, expected_state: str, expected_tracked: list[str] + ) -> None: + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert sorted(state.attributes[tracked_attr]) == expected_tracked + # The other collection must stay empty, they are tracked independently. + assert state.attributes[untracked_attr] == [] + + assert_zone("zone.test_zone", test_zone_states[0], []) + assert_zone("zone.home", home_states[0], []) + + # Entity enters the zone + hass.states.async_set( + one, "Test Zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]} + ) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[1], [one]) + assert_zone("zone.home", home_states[1], []) + + # A second entity enters the zone (case insensitive state) + hass.states.async_set( + two, "TEST zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]} + ) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[2], sorted([one, two])) + assert_zone("zone.home", home_states[2], []) + + # The first entity moves to another zone + hass.states.async_set(one, "home", {device_tracker.ATTR_IN_ZONES: ["zone.home"]}) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[3], [two]) + assert_zone("zone.home", home_states[3], [one]) + + # The first entity is in two zones + hass.states.async_set( + one, "home", {device_tracker.ATTR_IN_ZONES: ["zone.home", "zone.test_zone"]} + ) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[4], sorted([one, two])) + assert_zone("zone.home", home_states[4], [one]) + + # The first entity enters not_home + hass.states.async_set(one, "not_home", {device_tracker.ATTR_IN_ZONES: []}) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[5], [two]) + assert_zone("zone.home", home_states[5], []) + + # The second entity is removed + hass.states.async_remove(two) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[6], []) + assert_zone("zone.home", home_states[6], []) + + +async def test_persons_and_device_trackers_tracked_independently( + hass: HomeAssistant, +) -> None: + """Test persons and device trackers in the same zone don't interfere.""" info = { "name": "Test Zone", "latitude": 32.880837, @@ -852,112 +960,55 @@ async def test_state(hass: HomeAssistant) -> None: } assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) - assert len(hass.states.async_entity_ids(DOMAIN)) == 2 - state = hass.states.get("zone.test_zone") - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - - # Person entity enters zone hass.states.async_set( "person.person1", "Test Zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, ) + hass.states.async_set( + "device_tracker.tracker1", + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + ) await hass.async_block_till_done() state = hass.states.get("zone.test_zone") assert state + # Only persons are counted towards the state. assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] + assert state.attributes[ZoneEntityStateAttribute.PERSONS] == ["person.person1"] + assert state.attributes[ZoneEntityStateAttribute.DEVICE_TRACKERS] == [ + "device_tracker.tracker1" + ] - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - # Person entity enters zone (case insensitive) +async def test_trackers_present_before_zone_setup(hass: HomeAssistant) -> None: + """Test persons and device trackers already in a zone at setup are picked up.""" hass.states.async_set( - "person.person2", - "TEST zone", + "person.person1", + "Test Zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, ) + hass.states.async_set( + "device_tracker.tracker1", + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + ) + + info = { + "name": "Test Zone", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + "passive": False, + } + assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) await hass.async_block_till_done() state = hass.states.get("zone.test_zone") assert state - assert state.state == "2" - assert sorted(state.attributes[ATTR_PERSONS]) == [ - "person.person1", - "person.person2", + assert state.state == "1" + assert state.attributes[ZoneEntityStateAttribute.PERSONS] == ["person.person1"] + assert state.attributes[ZoneEntityStateAttribute.DEVICE_TRACKERS] == [ + "device_tracker.tracker1" ] - - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - - # Person entity enters another zone - hass.states.async_set( - "person.person1", - "home", - {device_tracker.ATTR_IN_ZONES: ["zone.home"]}, - ) - await hass.async_block_till_done() - - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person2"] - - state = hass.states.get("zone.home") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] - - # Person entity is in two zones - hass.states.async_set( - "person.person1", - "home", - {device_tracker.ATTR_IN_ZONES: ["zone.home", "zone.test_zone"]}, - ) - await hass.async_block_till_done() - - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "2" - assert sorted(state.attributes[ATTR_PERSONS]) == [ - "person.person1", - "person.person2", - ] - - state = hass.states.get("zone.home") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] - - # Person entity enters not_home - hass.states.async_set( - "person.person1", - "not_home", - {device_tracker.ATTR_IN_ZONES: []}, - ) - await hass.async_block_till_done() - - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person2"] - - # Person entity removed - hass.states.async_remove("person.person2") - await hass.async_block_till_done() - - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == []