mirror of
https://github.com/home-assistant/core.git
synced 2026-08-30 18:14:50 -05:00
Add zone conditions in / not in zone (#172810)
This commit is contained in:
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user