From 28d3117a88e2fe0452c91ba882d546a914b1d739 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 21 Mar 2022 18:41:55 +0100 Subject: [PATCH] Track number of persons in a Zone (#68473) --- homeassistant/components/zone/__init__.py | 59 +++++++++++++++++++- tests/components/zone/test_init.py | 66 +++++++++++++++++++++-- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index ef2d21281d1..ebde3328c02 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,6 +1,7 @@ """Support for the definition of zones.""" from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, cast @@ -9,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( ATTR_EDITABLE, + ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ICON, @@ -19,7 +21,10 @@ from homeassistant.const import ( CONF_RADIUS, EVENT_CORE_CONFIG_UPDATE, SERVICE_RELOAD, + STATE_HOME, + STATE_NOT_HOME, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback from homeassistant.helpers import ( @@ -27,6 +32,7 @@ from homeassistant.helpers import ( config_validation as cv, entity, entity_component, + event, service, storage, ) @@ -284,7 +290,10 @@ class Zone(entity.Entity): """Initialize the zone.""" self._config = config self.editable = True + self._attrs: dict | None = None + self._remove_listener: Callable[[], None] | None = None self._generate_attrs() + self._persons_in_zone: set[str] = set() @classmethod def from_yaml(cls, config: dict) -> Zone: @@ -295,9 +304,9 @@ class Zone(entity.Entity): return zone @property - def state(self) -> str: + def state(self) -> int: """Return the state property really does nothing for a zone.""" - return "zoning" + return len(self._persons_in_zone) @property def name(self) -> str: @@ -327,6 +336,35 @@ class Zone(entity.Entity): self._generate_attrs() self.async_write_ha_state() + @callback + def _person_state_change_listener(self, evt: Event) -> None: + person_entity_id = evt.data[ATTR_ENTITY_ID] + cur_count = len(self._persons_in_zone) + if self._state_is_in_zone(evt.data.get("new_state")): + self._persons_in_zone.add(person_entity_id) + elif person_entity_id in self._persons_in_zone: + self._persons_in_zone.remove(person_entity_id) + + if len(self._persons_in_zone) != cur_count: + self.async_write_ha_state() + + 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 + persons = self.hass.states.async_entity_ids(person_domain) + for person in persons: + if self._state_is_in_zone(self.hass.states.get(person)): + self._persons_in_zone.add(person) + + self.async_on_remove( + event.async_track_state_change_filtered( + self.hass, + event.TrackStates(False, set(), {person_domain}), + self._person_state_change_listener, + ).async_remove + ) + @callback def _generate_attrs(self) -> None: """Generate new attrs based on config.""" @@ -337,3 +375,20 @@ class Zone(entity.Entity): ATTR_PASSIVE: self._config[CONF_PASSIVE], ATTR_EDITABLE: self.editable, } + + @callback + def _state_is_in_zone(self, state: State | None) -> bool: + """Return if given state is in zone.""" + return ( + state is not None + and state.state + not in ( + STATE_NOT_HOME, + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + and ( + state.state.casefold() == self.name.casefold() + or (state.state == STATE_HOME and self.entity_id == ENTITY_ID_HOME) + ) + ) diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 8d0fddb921c..1e0c069e8ff 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -316,7 +316,7 @@ async def test_load_from_storage(hass, storage_setup): """Test set up from storage.""" assert await storage_setup() state = hass.states.get(f"{DOMAIN}.from_storage") - assert state.state == "zoning" + assert state.state == "0" assert state.name == "from storage" assert state.attributes.get(ATTR_EDITABLE) @@ -328,12 +328,12 @@ async def test_editable_state_attribute(hass, storage_setup): ) state = hass.states.get(f"{DOMAIN}.from_storage") - assert state.state == "zoning" + assert state.state == "0" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "from storage" assert state.attributes.get(ATTR_EDITABLE) state = hass.states.get(f"{DOMAIN}.yaml_option") - assert state.state == "zoning" + assert state.state == "0" assert not state.attributes.get(ATTR_EDITABLE) @@ -457,7 +457,7 @@ async def test_ws_create(hass, hass_ws_client, storage_setup): assert resp["success"] state = hass.states.get(input_entity_id) - assert state.state == "zoning" + assert state.state == "0" assert state.attributes["latitude"] == 3 assert state.attributes["longitude"] == 4 assert state.attributes["passive"] is True @@ -503,3 +503,61 @@ async def test_unavailable_zone(hass): assert zone.async_active_zone(hass, 0.0, 0.01) is None assert zone.in_zone(hass.states.get("zone.bla"), 0, 0) is False + + +async def test_state(hass): + """Test the state of a 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}) + + assert len(hass.states.async_entity_ids("zone")) == 2 + state = hass.states.get("zone.test_zone") + assert state.state == "0" + + # Person entity enters zone + hass.states.async_set( + "person.person1", + "Test Zone", + ) + await hass.async_block_till_done() + assert hass.states.get("zone.test_zone").state == "1" + assert hass.states.get("zone.home").state == "0" + + # Person entity enters zone (case insensitive) + hass.states.async_set( + "person.person2", + "TEST zone", + ) + await hass.async_block_till_done() + assert hass.states.get("zone.test_zone").state == "2" + assert hass.states.get("zone.home").state == "0" + + # Person entity enters another zone + hass.states.async_set( + "person.person1", + "home", + ) + await hass.async_block_till_done() + assert hass.states.get("zone.test_zone").state == "1" + assert hass.states.get("zone.home").state == "1" + + # Person entity enters not_home + hass.states.async_set( + "person.person1", + "not_home", + ) + await hass.async_block_till_done() + assert hass.states.get("zone.test_zone").state == "1" + assert hass.states.get("zone.home").state == "0" + + # Person entity removed + hass.states.async_remove("person.person2") + await hass.async_block_till_done() + assert hass.states.get("zone.test_zone").state == "0" + assert hass.states.get("zone.home").state == "0"