mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Add device_trackers state attribute to zone entity states (#181001)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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] == []
|
||||
|
||||
Reference in New Issue
Block a user