Add zone conditions in / not in zone (#172810)

This commit is contained in:
Erik Montnemery
2026-06-03 09:59:22 +00:00
committed by Franck Nijhof
parent b176fb2113
commit 8ce5ba2ba4
5 changed files with 386 additions and 2 deletions
+59 -2
View File
@@ -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
@@ -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
+8
View File
@@ -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"
@@ -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.",
+255
View File
@@ -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