mirror of
https://github.com/home-assistant/core.git
synced 2026-06-30 11:16:05 +01:00
645 lines
20 KiB
Python
645 lines
20 KiB
Python
"""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.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
|
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."""
|
|
config = {
|
|
"condition": "zone",
|
|
"options": {"entity_id": "device_tracker.cat", "zone": "zone.home"},
|
|
}
|
|
config = cv.CONDITION_SCHEMA(config)
|
|
config = await condition.async_validate_condition_config(hass, config)
|
|
test = await condition.async_from_config(hass, config)
|
|
|
|
with pytest.raises(ConditionError, match="no zone"):
|
|
zone_condition.zone(hass, zone_ent=None, entity="sensor.any")
|
|
|
|
with pytest.raises(ConditionError, match="unknown zone"):
|
|
test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
|
|
with pytest.raises(ConditionError, match="no entity"):
|
|
zone_condition.zone(hass, zone_ent="zone.home", entity=None)
|
|
|
|
with pytest.raises(ConditionError, match="unknown entity"):
|
|
test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.cat",
|
|
"home",
|
|
{"friendly_name": "cat"},
|
|
)
|
|
|
|
with pytest.raises(ConditionError, match="latitude"):
|
|
test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.cat",
|
|
"home",
|
|
{"friendly_name": "cat", "latitude": 2.1},
|
|
)
|
|
|
|
with pytest.raises(ConditionError, match="longitude"):
|
|
test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.cat",
|
|
"home",
|
|
{"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
|
|
# All okay, now test multiple failed conditions
|
|
assert test.async_check()
|
|
|
|
config = {
|
|
"condition": "zone",
|
|
"options": {
|
|
"entity_id": ["device_tracker.cat", "device_tracker.dog"],
|
|
"zone": ["zone.home", "zone.work"],
|
|
},
|
|
}
|
|
config = cv.CONDITION_SCHEMA(config)
|
|
config = await condition.async_validate_condition_config(hass, config)
|
|
test = await condition.async_from_config(hass, config)
|
|
|
|
with pytest.raises(ConditionError, match="dog"):
|
|
test.async_check()
|
|
|
|
with pytest.raises(ConditionError, match="work"):
|
|
test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"zone.work",
|
|
"zoning",
|
|
{"name": "work", "latitude": 20, "longitude": 10, "radius": 25000},
|
|
)
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.dog",
|
|
"work",
|
|
{"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1},
|
|
)
|
|
|
|
assert test.async_check()
|
|
|
|
|
|
async def test_zone_multiple_entities(hass: HomeAssistant) -> None:
|
|
"""Test with multiple entities in condition."""
|
|
config = {
|
|
"condition": "and",
|
|
"conditions": [
|
|
{
|
|
"alias": "Zone Condition",
|
|
"condition": "zone",
|
|
"options": {
|
|
"entity_id": ["device_tracker.person_1", "device_tracker.person_2"],
|
|
"zone": "zone.home",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
config = cv.CONDITION_SCHEMA(config)
|
|
config = await condition.async_validate_condition_config(hass, config)
|
|
test = await condition.async_from_config(hass, config)
|
|
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person_1",
|
|
"home",
|
|
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
hass.states.async_set(
|
|
"device_tracker.person_2",
|
|
"home",
|
|
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
assert test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person_1",
|
|
"home",
|
|
{"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1},
|
|
)
|
|
hass.states.async_set(
|
|
"device_tracker.person_2",
|
|
"home",
|
|
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
assert not test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person_1",
|
|
"home",
|
|
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
hass.states.async_set(
|
|
"device_tracker.person_2",
|
|
"home",
|
|
{"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1},
|
|
)
|
|
assert not test.async_check()
|
|
|
|
|
|
async def test_multiple_zones(hass: HomeAssistant) -> None:
|
|
"""Test with multiple entities in condition."""
|
|
config = {
|
|
"condition": "and",
|
|
"conditions": [
|
|
{
|
|
"condition": "zone",
|
|
"options": {
|
|
"entity_id": "device_tracker.person",
|
|
"zone": ["zone.home", "zone.work"],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
config = cv.CONDITION_SCHEMA(config)
|
|
config = await condition.async_validate_condition_config(hass, config)
|
|
test = await condition.async_from_config(hass, config)
|
|
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
hass.states.async_set(
|
|
"zone.work",
|
|
"zoning",
|
|
{"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10},
|
|
)
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person",
|
|
"home",
|
|
{"friendly_name": "person", "latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
assert test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person",
|
|
"home",
|
|
{"friendly_name": "person", "latitude": 20.1, "longitude": 10.1},
|
|
)
|
|
assert test.async_check()
|
|
|
|
hass.states.async_set(
|
|
"device_tracker.person",
|
|
"home",
|
|
{"friendly_name": "person", "latitude": 50.1, "longitude": 20.1},
|
|
)
|
|
assert not test.async_check()
|
|
|
|
|
|
@pytest.mark.parametrize("entity_id", ["device_tracker.cat", "person.bob"])
|
|
async def test_zone_condition_prefers_in_zones_over_coordinates(
|
|
hass: HomeAssistant, entity_id: str
|
|
) -> None:
|
|
"""Test the legacy zone condition prefers in_zones over coordinates.
|
|
|
|
For device_tracker and person entities the ``in_zones`` attribute is
|
|
authoritative; coordinates are only consulted as a fallback.
|
|
"""
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
|
|
# in_zones lists the zone but the coordinates are far away -> in_zones wins.
|
|
hass.states.async_set(
|
|
entity_id,
|
|
"home",
|
|
{"latitude": 50.0, "longitude": 50.0, "in_zones": ["zone.home"]},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", entity_id) is True
|
|
|
|
# in_zones is empty but the coordinates are inside the zone -> in_zones wins.
|
|
hass.states.async_set(
|
|
entity_id,
|
|
"not_home",
|
|
{"latitude": 2.1, "longitude": 1.1, "in_zones": []},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", entity_id) is False
|
|
|
|
# in_zones lists a different zone -> not in the target zone.
|
|
hass.states.async_set(
|
|
entity_id,
|
|
"work",
|
|
{"latitude": 2.1, "longitude": 1.1, "in_zones": ["zone.work"]},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", entity_id) is False
|
|
|
|
|
|
async def test_zone_condition_ignores_in_zones_for_other_domains(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Test in_zones is only honored for device_tracker and person entities.
|
|
|
|
An entity in another domain that exposes an ``in_zones`` attribute is
|
|
matched by coordinates instead.
|
|
"""
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
|
|
# in_zones claims the zone but the coordinates are far away -> coordinates win.
|
|
hass.states.async_set(
|
|
"sensor.tracker",
|
|
"home",
|
|
{"latitude": 50.0, "longitude": 50.0, "in_zones": ["zone.home"]},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", "sensor.tracker") is False
|
|
|
|
# in_zones is empty but the coordinates are inside the zone -> coordinates win.
|
|
hass.states.async_set(
|
|
"sensor.tracker",
|
|
"home",
|
|
{"latitude": 2.1, "longitude": 1.1, "in_zones": []},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", "sensor.tracker") is True
|
|
|
|
|
|
async def test_zone_condition_falls_back_to_coordinates(hass: HomeAssistant) -> None:
|
|
"""Test the legacy zone condition uses coordinates without an in_zones attr.
|
|
|
|
Coordinate-only entities (e.g. geo_location) report no ``in_zones``.
|
|
"""
|
|
hass.states.async_set(
|
|
"zone.home",
|
|
"zoning",
|
|
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
|
|
)
|
|
|
|
# Inside the zone by coordinates, no in_zones attribute.
|
|
hass.states.async_set(
|
|
"geo_location.quake",
|
|
"1.0",
|
|
{"latitude": 2.1, "longitude": 1.1},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", "geo_location.quake") is True
|
|
|
|
# Outside the zone by coordinates.
|
|
hass.states.async_set(
|
|
"geo_location.quake",
|
|
"1.0",
|
|
{"latitude": 50.0, "longitude": 50.0},
|
|
)
|
|
assert zone_condition.zone(hass, "zone.home", "geo_location.quake") is False
|
|
|
|
|
|
# --- 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",
|
|
"supports_target",
|
|
),
|
|
[
|
|
("zone.in_zone", {"zone": TARGET_ZONE}, True, True, True),
|
|
("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True, True),
|
|
("zone.occupancy_is_detected", {"zone": ZONE_HOME}, False, True, False),
|
|
("zone.occupancy_is_not_detected", {"zone": ZONE_HOME}, False, True, False),
|
|
],
|
|
)
|
|
async def test_zone_condition_options_validation(
|
|
hass: HomeAssistant,
|
|
condition_key: str,
|
|
base_options: dict[str, Any] | None,
|
|
supports_behavior: bool,
|
|
supports_duration: bool,
|
|
supports_target: 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,
|
|
supports_target=supports_target,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("condition_key", "config"),
|
|
[
|
|
(
|
|
"zone.in_zone",
|
|
{"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}},
|
|
),
|
|
(
|
|
"zone.not_in_zone",
|
|
{"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}},
|
|
),
|
|
(
|
|
"zone.occupancy_is_detected",
|
|
{"options": {"zone": "light.x"}},
|
|
),
|
|
(
|
|
"zone.occupancy_is_not_detected",
|
|
{"options": {"zone": "light.x"}},
|
|
),
|
|
],
|
|
)
|
|
async def test_zone_condition_rejects_non_zone_entity_id(
|
|
hass: HomeAssistant, condition_key: str, config: dict[str, Any]
|
|
) -> 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, **config},
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
|
|
# --- Zone occupancy condition tests ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("condition_key", "zone_state", "expected"),
|
|
[
|
|
# occupancy_is_detected — true when count >= 1
|
|
pytest.param("zone.occupancy_is_detected", "1", True, id="detected_1"),
|
|
pytest.param("zone.occupancy_is_detected", "3", True, id="detected_3"),
|
|
pytest.param("zone.occupancy_is_detected", "0", False, id="detected_0"),
|
|
pytest.param(
|
|
"zone.occupancy_is_detected",
|
|
STATE_UNAVAILABLE,
|
|
False,
|
|
id="detected_unavailable",
|
|
),
|
|
pytest.param(
|
|
"zone.occupancy_is_detected", STATE_UNKNOWN, False, id="detected_unknown"
|
|
),
|
|
# occupancy_is_not_detected — true only when count == 0
|
|
pytest.param("zone.occupancy_is_not_detected", "0", True, id="empty_0"),
|
|
pytest.param("zone.occupancy_is_not_detected", "1", False, id="empty_1"),
|
|
pytest.param("zone.occupancy_is_not_detected", "3", False, id="empty_3"),
|
|
# Unavailable / unknown are not "empty" — they're indeterminate.
|
|
pytest.param(
|
|
"zone.occupancy_is_not_detected",
|
|
STATE_UNAVAILABLE,
|
|
False,
|
|
id="empty_unavailable",
|
|
),
|
|
pytest.param(
|
|
"zone.occupancy_is_not_detected",
|
|
STATE_UNKNOWN,
|
|
False,
|
|
id="empty_unknown",
|
|
),
|
|
],
|
|
)
|
|
async def test_zone_occupancy_condition_evaluates(
|
|
hass: HomeAssistant,
|
|
condition_key: str,
|
|
zone_state: str,
|
|
expected: bool,
|
|
) -> None:
|
|
"""Test occupancy conditions evaluate against the zone's integer state."""
|
|
hass.states.async_set(ZONE_HOME, zone_state)
|
|
await hass.async_block_till_done()
|
|
|
|
config = await condition.async_validate_condition_config(
|
|
hass, {"condition": condition_key, "options": {"zone": ZONE_HOME}}
|
|
)
|
|
test = await condition.async_from_config(hass, config)
|
|
assert test.async_check() is expected
|