From 8ce5ba2ba41e7378491ffae422138ac982dcd562 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 10:40:43 +0200 Subject: [PATCH] Add zone conditions in / not in zone (#172810) --- homeassistant/components/zone/condition.py | 61 ++++- homeassistant/components/zone/conditions.yaml | 26 ++ homeassistant/components/zone/icons.json | 8 + homeassistant/components/zone/strings.json | 38 +++ tests/components/zone/test_condition.py | 255 ++++++++++++++++++ 5 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/zone/conditions.yaml diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 130648f5a279..c2f712ea6e14 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -4,6 +4,7 @@ from typing import Any, Unpack, cast import voluptuous as vol +from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, @@ -17,15 +18,21 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionCheckParams, ConditionConfig, + EntityConditionBase, ) from homeassistant.helpers.typing import ConfigType from . import in_zone +from .const import DOMAIN _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -149,11 +156,61 @@ class ZoneCondition(Condition): return all_ok +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(value_source=ATTR_IN_ZONES), + "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), +} + +_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + }, + } +) + + +class _ZoneTargetConditionBase(EntityConditionBase): + """Base for zone-target conditions on person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the condition.""" + super().__init__(hass, config) + assert config.options is not None + self._zone: str = config.options[CONF_ZONE] + + def _in_target_zone(self, entity_state: State) -> bool: + """Check if the entity is currently in the selected zone.""" + in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class InZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is in the selected zone.""" + return self._in_target_zone(entity_state) + + +class NotInZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is not in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is not in the selected zone.""" + return not self._in_target_zone(entity_state) + + CONDITIONS: dict[str, type[Condition]] = { "_": ZoneCondition, + "in_zone": InZoneCondition, + "not_in_zone": NotInZoneCondition, } async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the sun conditions.""" + """Return the zone conditions.""" return CONDITIONS diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml new file mode 100644 index 000000000000..3853bc9beb75 --- /dev/null +++ b/homeassistant/components/zone/conditions.yaml @@ -0,0 +1,26 @@ +.condition_zone: &condition_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +in_zone: *condition_zone +not_in_zone: *condition_zone diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index f582e6b65a49..7d082d5f0d1d 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "in_zone": { + "condition": "mdi:map-marker-check" + }, + "not_in_zone": { + "condition": "mdi:map-marker-remove" + } + }, "services": { "reload": { "service": "mdi:reload" diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 43d0c2986c2e..133bf0e15ab6 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -1,10 +1,48 @@ { "common": { + "condition_behavior_name": "Check when", + "condition_for_name": "For at least", + "condition_zone_description": "The zone to test against.", + "condition_zone_name": "Zone", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least", "trigger_zone_description": "The zone to trigger on.", "trigger_zone_name": "Zone" }, + "conditions": { + "in_zone": { + "description": "Tests if one or more persons or device trackers are in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is in zone" + }, + "not_in_zone": { + "description": "Tests if one or more persons or device trackers are not in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is not in zone" + } + }, "services": { "reload": { "description": "Reloads zones from the YAML-configuration.", diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index 391593019b33..4a8d7408f216 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -1,12 +1,28 @@ """The tests for the location condition.""" +from datetime import timedelta +from typing import Any + +from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.zone import condition as zone_condition from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" @@ -206,3 +222,242 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, ) assert not test.async_check() + + +# --- New-style zone condition tests --- + +ZONE_HOME = "zone.home" +ZONE_WORK = "zone.work" +IN_ZONES_HOME = {"in_zones": [ZONE_HOME]} +IN_ZONES_WORK = {"in_zones": [ZONE_WORK]} +IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []} +TARGET_ZONE = ZONE_HOME + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("zone.in_zone", {"zone": TARGET_ZONE}, True, True), + ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True), + ], +) +async def test_zone_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that zone conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize("condition_key", ["zone.in_zone", "zone.not_in_zone"]) +async def test_zone_condition_rejects_non_zone_entity_id( + hass: HomeAssistant, condition_key: str +) -> None: + """Test that the zone option must reference entities in the zone domain.""" + with pytest.raises(vol.Invalid): + await condition.async_validate_condition_config( + hass, + { + "condition": condition_key, + "target": {"entity_id": "person.alice"}, + "options": {"zone": "person.alice"}, + }, + ) + + +@pytest.fixture +async def target_zone_entities( + hass: HomeAssistant, domain: str +) -> dict[str, list[str]]: + """Create multiple zone-trackable entities associated with different targets.""" + return await target_entities(hass, domain, domain_excluded="sensor") + + +# `in_zone` is True for states where the entity carries the target zone in +# `in_zones`; `not_in_zone` flips the relation. +_ZONE_CONDITION_STATES_ANY = [ + *parametrize_condition_states_any( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_any( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +_ZONE_CONDITION_STATES_ALL = [ + *parametrize_condition_states_all( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_all( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]: + """Parametrize target entities for all supported zone condition domains.""" + return [ + (*params, domain) + for domain in ("person", "device_tracker") + for params in parametrize_target_entities(domain) + ] + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ANY, +) +async def test_zone_condition_behavior_any( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=any.""" + await assert_condition_behavior_any( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ALL, +) +async def test_zone_condition_behavior_all( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=all.""" + await assert_condition_behavior_all( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +async def test_in_zone_condition_for_attribute_only_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test `for:` anchors to in_zones updates, not state.state changes. + + A person already "home" who enters an overlapping zone (e.g. zone.coffee) + keeps state.state == "home" while in_zones grows. `for: 5m` on + in_zone(zone.coffee) must start counting from when in_zones changed, not + from the (older) last state.state transition. + """ + coffee_zone = "zone.coffee" + + # Person at home but not yet in the coffee zone. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME]}, + ) + await hass.async_block_till_done() + + # Time passes — state.state's last_changed sits 10 minutes in the past. + freezer.tick(timedelta(minutes=10)) + + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.alice"}, + "options": {"zone": coffee_zone, "for": {"minutes": 5}}, + }, + ) + test = await condition.async_from_config(hass, config) + + # in_zones gains the coffee zone; state.state stays "home", so last_changed + # is untouched and only last_updated advances. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME, coffee_zone]}, + ) + await hass.async_block_till_done() + + # Just entered; `for: 5m` must not be satisfied yet. (Without value_source + # set on the DomainSpec, the anchor would be last_changed from 10 minutes + # ago and this would incorrectly evaluate to True.) + assert test.async_check() is False + + # After the duration elapses, the condition is satisfied. + freezer.tick(timedelta(minutes=6)) + assert test.async_check() is True