mirror of
https://github.com/home-assistant/core.git
synced 2026-09-28 16:29:19 +01:00
Add zone occupancy conditions (#172896)
This commit is contained in:
@@ -10,7 +10,9 @@ from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_FOR,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
CONF_ZONE,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
@@ -23,6 +25,8 @@ from homeassistant.helpers.automation import (
|
||||
move_top_level_schema_fields_to_options,
|
||||
)
|
||||
from homeassistant.helpers.condition import (
|
||||
ATTR_BEHAVIOR,
|
||||
BEHAVIOR_ANY,
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
|
||||
Condition,
|
||||
ConditionCheckParams,
|
||||
@@ -204,10 +208,75 @@ class NotInZoneCondition(_ZoneTargetConditionBase):
|
||||
return not self._in_target_zone(entity_state)
|
||||
|
||||
|
||||
_OCCUPANCY_CONDITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS, default={}): {
|
||||
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _ZoneOccupancyConditionBase(EntityConditionBase):
|
||||
"""Base for zone occupancy conditions (single zone, no behavior)."""
|
||||
|
||||
_domain_specs = {"zone": DomainSpec()}
|
||||
_schema = _OCCUPANCY_CONDITION_SCHEMA
|
||||
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config and synthesize a target from the zone option.
|
||||
|
||||
We synthesize a target because we allow users to pick a single zone
|
||||
to monitor, not a target.
|
||||
"""
|
||||
config = cast(ConfigType, cls._schema(config))
|
||||
zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE]
|
||||
config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]}
|
||||
# `behavior` is needed by `EntityConditionBase.__init__`
|
||||
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _occupancy_count(entity_state: State) -> int | None:
|
||||
"""Return the zone's persons-in-zone count; None if unparsable."""
|
||||
try:
|
||||
return int(entity_state.state)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_occupied(cls, entity_state: State) -> bool:
|
||||
"""Return True if the zone has at least one occupant."""
|
||||
count = cls._occupancy_count(entity_state)
|
||||
return count is not None and count >= 1
|
||||
|
||||
|
||||
class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase):
|
||||
"""Condition: the selected zone is occupied."""
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check that the zone is occupied."""
|
||||
return self._is_occupied(entity_state)
|
||||
|
||||
|
||||
class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase):
|
||||
"""Condition: the selected zone is empty."""
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check that the zone is empty (count == 0)."""
|
||||
return self._occupancy_count(entity_state) == 0
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"_": ZoneCondition,
|
||||
"in_zone": InZoneCondition,
|
||||
"not_in_zone": NotInZoneCondition,
|
||||
"occupancy_is_detected": OccupancyIsDetectedCondition,
|
||||
"occupancy_is_not_detected": OccupancyIsNotDetectedCondition,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,3 +24,19 @@
|
||||
|
||||
in_zone: *condition_zone
|
||||
not_in_zone: *condition_zone
|
||||
|
||||
.condition_occupancy: &condition_occupancy
|
||||
fields:
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
zone:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: zone
|
||||
|
||||
occupancy_is_detected: *condition_occupancy
|
||||
occupancy_is_not_detected: *condition_occupancy
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
},
|
||||
"not_in_zone": {
|
||||
"condition": "mdi:map-marker-remove"
|
||||
},
|
||||
"occupancy_is_detected": {
|
||||
"condition": "mdi:account-group"
|
||||
},
|
||||
"occupancy_is_not_detected": {
|
||||
"condition": "mdi:account-off"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -41,6 +41,32 @@
|
||||
}
|
||||
},
|
||||
"name": "Is not in zone"
|
||||
},
|
||||
"occupancy_is_detected": {
|
||||
"description": "Tests if a zone is occupied.",
|
||||
"fields": {
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::condition_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "The zone to monitor.",
|
||||
"name": "[%key:component::zone::common::condition_zone_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Zone occupancy is detected"
|
||||
},
|
||||
"occupancy_is_not_detected": {
|
||||
"description": "Tests if a zone is empty.",
|
||||
"fields": {
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::condition_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]",
|
||||
"name": "[%key:component::zone::common::condition_zone_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Zone occupancy is not detected"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
+29
-10
@@ -1485,12 +1485,12 @@ async def _validate_condition_options(
|
||||
options: dict[str, Any] | None,
|
||||
*,
|
||||
valid: bool,
|
||||
supports_target: bool = True,
|
||||
) -> None:
|
||||
"""Assert that a condition accepts or rejects the given options."""
|
||||
config: dict[str, Any] = {
|
||||
CONF_CONDITION: condition,
|
||||
CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
|
||||
}
|
||||
config: dict[str, Any] = {CONF_CONDITION: condition}
|
||||
if supports_target:
|
||||
config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"}
|
||||
if options is not None:
|
||||
config[CONF_OPTIONS] = options
|
||||
if valid:
|
||||
@@ -1536,6 +1536,7 @@ async def assert_condition_options_supported(
|
||||
*,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
supports_target: bool = True,
|
||||
) -> None:
|
||||
"""Assert which options a condition supports.
|
||||
|
||||
@@ -1555,9 +1556,15 @@ async def assert_condition_options_supported(
|
||||
# Minimal config should always be valid
|
||||
# If there are no base options, also test that options can be omitted or be empty
|
||||
supports_empty = not bool(base_options)
|
||||
await _validate_condition_options(hass, condition, None, valid=supports_empty)
|
||||
await _validate_condition_options(hass, condition, {}, valid=supports_empty)
|
||||
await _validate_condition_options(hass, condition, base_options, valid=True)
|
||||
await _validate_condition_options(
|
||||
hass, condition, None, valid=supports_empty, supports_target=supports_target
|
||||
)
|
||||
await _validate_condition_options(
|
||||
hass, condition, {}, valid=supports_empty, supports_target=supports_target
|
||||
)
|
||||
await _validate_condition_options(
|
||||
hass, condition, base_options, valid=True, supports_target=supports_target
|
||||
)
|
||||
|
||||
def _merge(extra: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**(base_options or {}), **extra}
|
||||
@@ -1565,18 +1572,30 @@ async def assert_condition_options_supported(
|
||||
# Behavior
|
||||
for behavior in ("any", "all"):
|
||||
await _validate_condition_options(
|
||||
hass, condition, _merge({"behavior": behavior}), valid=supports_behavior
|
||||
hass,
|
||||
condition,
|
||||
_merge({"behavior": behavior}),
|
||||
valid=supports_behavior,
|
||||
supports_target=supports_target,
|
||||
)
|
||||
|
||||
# Duration
|
||||
for for_value in ({"seconds": 5}, "00:00:05", 5):
|
||||
await _validate_condition_options(
|
||||
hass, condition, _merge({"for": for_value}), valid=supports_duration
|
||||
hass,
|
||||
condition,
|
||||
_merge({"for": for_value}),
|
||||
valid=supports_duration,
|
||||
supports_target=supports_target,
|
||||
)
|
||||
|
||||
# Unknown option should always be rejected
|
||||
await _validate_condition_options(
|
||||
hass, condition, _merge({"unknown_option": True}), valid=False
|
||||
hass,
|
||||
condition,
|
||||
_merge({"unknown_option": True}),
|
||||
valid=False,
|
||||
supports_target=supports_target,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -235,10 +236,18 @@ TARGET_ZONE = ZONE_HOME
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
(
|
||||
"condition_key",
|
||||
"base_options",
|
||||
"supports_behavior",
|
||||
"supports_duration",
|
||||
"supports_target",
|
||||
),
|
||||
[
|
||||
("zone.in_zone", {"zone": TARGET_ZONE}, True, True),
|
||||
("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True),
|
||||
("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(
|
||||
@@ -247,6 +256,7 @@ async def test_zone_condition_options_validation(
|
||||
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(
|
||||
@@ -255,22 +265,39 @@ async def test_zone_condition_options_validation(
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
supports_target=supports_target,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("condition_key", ["zone.in_zone", "zone.not_in_zone"])
|
||||
@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
|
||||
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,
|
||||
"target": {"entity_id": "person.alice"},
|
||||
"options": {"zone": "person.alice"},
|
||||
},
|
||||
{"condition": condition_key, **config},
|
||||
)
|
||||
|
||||
|
||||
@@ -461,3 +488,58 @@ async def test_in_zone_condition_for_attribute_only_change(
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user