Add device_trackers state attribute to zone entity states (#181001)

This commit is contained in:
Erik Montnemery
2026-09-01 15:53:18 +02:00
committed by GitHub
parent 1ad12ee9d7
commit f5c66bb8ba
5 changed files with 187 additions and 102 deletions
+38 -9
View File
@@ -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,
}
+1
View File
@@ -13,6 +13,7 @@ class ZoneEntityStateAttribute(StrEnum):
RADIUS = "radius"
PASSIVE = "passive"
PERSONS = "persons"
DEVICE_TRACKERS = "device_trackers"
EDITABLE = "editable"
@@ -38,6 +38,10 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
<ZoneEntityStateAttribute.DEVICE_TRACKERS: 'device_trackers'>: list([
'device_tracker.demo_scanner',
'device_tracker.demo_tracker',
]),
<ZoneEntityStateAttribute.EDITABLE: 'editable'>: True,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'test home',
<EntityStateAttribute.ICON: 'icon'>: 'mdi:home',
+1 -1
View File
@@ -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():
+143 -92
View File
@@ -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] == []