Add zone occupancy conditions (#172896)

This commit is contained in:
Erik Montnemery
2026-06-03 10:02:17 +00:00
committed by Franck Nijhof
parent 6c87284dee
commit 083af9ccc7
6 changed files with 238 additions and 20 deletions
@@ -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
+6
View File
@@ -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
View File
@@ -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,
)
+92 -10
View File
@@ -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